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: Vec<secrecy::Secret<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: 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: vec![secrecy::Secret::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::Secret<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::Secret<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::Secret::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: 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.clone()));
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(|p| p.to_path_buf())
2658        .unwrap_or_else(|| parent.to_path_buf())
2659}
2660
2661fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2662    let mut hits = collect_result_json_candidates(scan_root);
2663    if hits.is_empty() {
2664        hits = collect_result_json_candidates(parent);
2665    }
2666    hits.sort();
2667    hits
2668}
2669
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.clone()))
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 !expected_run_id.is_empty() {
2780        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == expected_run_id) {
2781            entry.html_path = Some(html_path.clone());
2782            let _ = reg.save(&state.registry_path);
2783            drop(reg);
2784            state.artifacts.lock().await.remove(&expected_run_id);
2785            return redirect_or_json_ok(want_json, &safe_redirect);
2786        }
2787    }
2788
2789    drop(reg);
2790    let hint = if state.server_mode {
2791        String::new()
2792    } else {
2793        format!(
2794            "\n\nSearched folder : {}\nHTML found      : {}",
2795            scan_root.display(),
2796            html_path.display()
2797        )
2798    };
2799    locate_handler_err(
2800        want_json,
2801        format!(
2802            "Could not link this report.\n\n\
2803             No result_*.json was found in the selected folder. \
2804             Make sure you selected the top-level scan output folder \
2805             (the one that contains html/, json/, pdf/ subfolders).{hint}"
2806        ),
2807        &csp_nonce,
2808    )
2809}
2810
2811/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
2812fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2813    fs::read_dir(dir)
2814        .ok()?
2815        .flatten()
2816        .map(|e| e.path())
2817        .find(|p| {
2818            p.is_file()
2819                && p.file_stem()
2820                    .and_then(|n| n.to_str())
2821                    .is_some_and(|n| n.starts_with("result"))
2822                && p.extension()
2823                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2824        })
2825}
2826
2827#[derive(Deserialize)]
2828struct LocateReportsDirForm {
2829    folder_path: String,
2830}
2831
2832#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
2833async fn locate_reports_dir_handler(
2834    State(state): State<AppState>,
2835    Form(form): Form<LocateReportsDirForm>,
2836) -> impl IntoResponse {
2837    if state.server_mode {
2838        return StatusCode::NOT_FOUND.into_response();
2839    }
2840    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
2841        Ok(p) => strip_unc_prefix(p),
2842        Err(_) => {
2843            return axum::response::Redirect::to(
2844                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
2845            )
2846            .into_response();
2847        }
2848    };
2849    if !folder.is_dir() {
2850        return axum::response::Redirect::to(
2851            "/view-reports?error=Selected+path+is+not+a+directory.",
2852        )
2853        .into_response();
2854    }
2855
2856    let candidates = collect_result_json_candidates(&folder);
2857
2858    if candidates.is_empty() {
2859        return axum::response::Redirect::to(
2860            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
2861        )
2862        .into_response();
2863    }
2864
2865    let mut linked_count: usize = 0;
2866    let mut reg = state.registry.lock().await;
2867    for json_path in candidates {
2868        let Some(parent) = json_path.parent().map(PathBuf::from) else {
2869            continue;
2870        };
2871        if is_dir_already_registered(&reg, &parent) {
2872            continue;
2873        }
2874        let Some(entry) = build_registry_entry_from_json(json_path) else {
2875            continue;
2876        };
2877        reg.add_entry(entry);
2878        linked_count += 1;
2879    }
2880    let _ = reg.save(&state.registry_path);
2881    drop(reg);
2882
2883    if linked_count == 0 {
2884        return axum::response::Redirect::to(
2885            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
2886        )
2887        .into_response();
2888    }
2889    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
2890}
2891
2892#[derive(Deserialize)]
2893struct RelocateScanForm {
2894    run_id: String,
2895    folder_path: String,
2896    redirect_url: String,
2897}
2898
2899/// JSON-or-HTML error for relocate_scan_handler folder-level errors.
2900/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
2901fn relocate_folder_err(
2902    want_json: bool,
2903    status: StatusCode,
2904    msg: &str,
2905    run_id: &str,
2906    folder_hint: &str,
2907    redirect_url: &str,
2908    csp_nonce: &str,
2909) -> Response {
2910    if want_json {
2911        (
2912            status,
2913            axum::Json(serde_json::json!({"ok": false, "message": msg})),
2914        )
2915            .into_response()
2916    } else {
2917        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
2918    }
2919}
2920
2921async fn relocate_scan_handler(
2922    State(state): State<AppState>,
2923    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2924    headers: axum::http::HeaderMap,
2925    Form(form): Form<RelocateScanForm>,
2926) -> impl IntoResponse {
2927    let want_json = headers
2928        .get(axum::http::header::ACCEPT)
2929        .and_then(|v| v.to_str().ok())
2930        .is_some_and(|v| v.contains("application/json"));
2931    if state.server_mode {
2932        return StatusCode::NOT_FOUND.into_response();
2933    }
2934
2935    let run_id = form.run_id.trim().to_string();
2936    let redirect_url = form.redirect_url.trim().to_string();
2937
2938    let run_exists = {
2939        let reg = state.registry.lock().await;
2940        reg.find_by_run_id(&run_id).is_some()
2941    };
2942    if !run_exists {
2943        if want_json {
2944            return (
2945                StatusCode::NOT_FOUND,
2946                axum::Json(serde_json::json!({
2947                    "ok": false,
2948                    "message": format!("Run ID '{run_id}' not found in registry.")
2949                })),
2950            )
2951                .into_response();
2952        }
2953        let html = ErrorTemplate {
2954            message: format!("Run ID '{run_id}' not found in registry."),
2955            last_report_url: Some("/compare-scans".to_string()),
2956            last_report_label: Some("Compare Scans".to_string()),
2957            run_id: Some(run_id.clone()),
2958            error_code: Some(404),
2959            csp_nonce: csp_nonce.clone(),
2960            version: env!("CARGO_PKG_VERSION"),
2961        }
2962        .render()
2963        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2964        return Html(html).into_response();
2965    }
2966
2967    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
2968        Ok(p) => strip_unc_prefix(p),
2969        Err(_) => {
2970            return relocate_folder_err(
2971                want_json,
2972                StatusCode::UNPROCESSABLE_ENTITY,
2973                "Folder not found or path is invalid.",
2974                &run_id,
2975                form.folder_path.trim(),
2976                &redirect_url,
2977                &csp_nonce,
2978            );
2979        }
2980    };
2981    if !folder.is_dir() {
2982        return relocate_folder_err(
2983            want_json,
2984            StatusCode::UNPROCESSABLE_ENTITY,
2985            "Selected path is not a directory.",
2986            &run_id,
2987            &folder.display().to_string(),
2988            &redirect_url,
2989            &csp_nonce,
2990        );
2991    }
2992
2993    let json_candidates = find_result_files_by_ext(&folder, "json");
2994    if json_candidates.is_empty() {
2995        let msg = format!(
2996            "No result JSON files found in the selected folder.\nSearched: {}",
2997            folder.display()
2998        );
2999        return relocate_folder_err(
3000            want_json,
3001            StatusCode::UNPROCESSABLE_ENTITY,
3002            &msg,
3003            &run_id,
3004            &folder.display().to_string(),
3005            &redirect_url,
3006            &csp_nonce,
3007        );
3008    }
3009
3010    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3011        let msg = format!(
3012            "No matching scan found in the selected folder.\n\
3013             The JSON files present do not contain run ID: {run_id}\n\
3014             Searched: {}",
3015            folder.display()
3016        );
3017        return relocate_folder_err(
3018            want_json,
3019            StatusCode::UNPROCESSABLE_ENTITY,
3020            &msg,
3021            &run_id,
3022            &folder.display().to_string(),
3023            &redirect_url,
3024            &csp_nonce,
3025        );
3026    };
3027
3028    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3029    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3030    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3031
3032    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3033        redirect_url
3034    } else {
3035        "/compare-scans".to_string()
3036    };
3037    redirect_or_json_ok(want_json, &safe_redirect)
3038}
3039
3040fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3041    let mut out = Vec::new();
3042    collect_scan_files_by_ext(folder, ext, &mut out);
3043    if let Ok(rd) = fs::read_dir(folder) {
3044        for entry in rd.flatten() {
3045            let sub = entry.path();
3046            if sub.is_dir() {
3047                collect_scan_files_by_ext(&sub, ext, &mut out);
3048            }
3049        }
3050    }
3051    out
3052}
3053
3054fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3055    let Ok(rd) = fs::read_dir(dir) else { return };
3056    for entry in rd.flatten() {
3057        let p = entry.path();
3058        if p.is_file()
3059            && p.file_stem()
3060                .and_then(|n| n.to_str())
3061                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3062            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3063        {
3064            out.push(p);
3065        }
3066    }
3067}
3068
3069fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3070    candidates
3071        .iter()
3072        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3073        .cloned()
3074}
3075
3076async fn update_run_file_paths(
3077    state: &AppState,
3078    run_id: &str,
3079    json_path: PathBuf,
3080    html_path: Option<PathBuf>,
3081    pdf_path: Option<PathBuf>,
3082) {
3083    let mut reg = state.registry.lock().await;
3084    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3085        entry.json_path = Some(json_path);
3086        if let Some(hp) = html_path {
3087            entry.html_path = Some(hp);
3088        }
3089        if let Some(pp) = pdf_path {
3090            entry.pdf_path = Some(pp);
3091        }
3092    }
3093    let _ = reg.save(&state.registry_path);
3094}
3095
3096fn missing_scan_relocate_response(
3097    message: &str,
3098    run_id: &str,
3099    folder_hint: &str,
3100    redirect_url: &str,
3101    server_mode: bool,
3102    csp_nonce: &str,
3103) -> axum::response::Response {
3104    let html = RelocateScanTemplate {
3105        message: message.to_string(),
3106        run_id: run_id.to_string(),
3107        folder_hint: folder_hint.to_string(),
3108        redirect_url: redirect_url.to_string(),
3109        server_mode,
3110        csp_nonce: csp_nonce.to_owned(),
3111        version: env!("CARGO_PKG_VERSION"),
3112    }
3113    .render()
3114    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3115    (StatusCode::NOT_FOUND, Html(html)).into_response()
3116}
3117
3118// ── Watched-directory helpers ─────────────────────────────────────────────────
3119
3120/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3121fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3122    let mut candidates = Vec::new();
3123    if let Some(j) = find_result_json_in_dir(folder) {
3124        candidates.push(j);
3125    }
3126    if let Ok(dir_entries) = fs::read_dir(folder) {
3127        for entry in dir_entries.flatten() {
3128            let sub = entry.path();
3129            if sub.is_dir() {
3130                if let Some(j) = find_result_json_in_dir(&sub) {
3131                    candidates.push(j);
3132                }
3133            }
3134        }
3135    }
3136    candidates
3137}
3138
3139fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3140    reg.entries.iter().any(|e| {
3141        let dir_match = e
3142            .json_path
3143            .as_ref()
3144            .and_then(|p| p.parent())
3145            .is_some_and(|p| p == parent)
3146            || e.html_path
3147                .as_ref()
3148                .and_then(|p| p.parent())
3149                .is_some_and(|p| p == parent);
3150        dir_match
3151            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3152                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3153    })
3154}
3155
3156fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3157    let parent = json_path.parent()?.to_path_buf();
3158    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3159        rd.flatten()
3160            .map(|e| e.path())
3161            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3162    });
3163    let run = read_json(&json_path).ok()?;
3164    let project_label = run.input_roots.first().map_or_else(
3165        || "Unknown Project".to_string(),
3166        |r| sanitize_project_label(r),
3167    );
3168    Some(RegistryEntry {
3169        run_id: run.tool.run_id.clone(),
3170        timestamp_utc: run.tool.timestamp_utc,
3171        project_label,
3172        input_roots: run.input_roots.clone(),
3173        json_path: Some(json_path),
3174        html_path,
3175        pdf_path: None,
3176        csv_path: None,
3177        xlsx_path: None,
3178        summary: ScanSummarySnapshot {
3179            files_analyzed: run.summary_totals.files_analyzed,
3180            files_skipped: run.summary_totals.files_skipped,
3181            total_physical_lines: run.summary_totals.total_physical_lines,
3182            code_lines: run.summary_totals.code_lines,
3183            comment_lines: run.summary_totals.comment_lines,
3184            blank_lines: run.summary_totals.blank_lines,
3185            functions: run.summary_totals.functions,
3186            classes: run.summary_totals.classes,
3187            variables: run.summary_totals.variables,
3188            imports: run.summary_totals.imports,
3189            test_count: run.summary_totals.test_count,
3190            coverage_lines_found: run.summary_totals.coverage_lines_found,
3191            coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3192            coverage_functions_found: run.summary_totals.coverage_functions_found,
3193            coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3194            coverage_branches_found: run.summary_totals.coverage_branches_found,
3195            coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3196        },
3197        git_branch: run.git_branch.clone(),
3198        git_commit: run.git_commit_short.clone(),
3199        git_author: run.git_commit_author.clone(),
3200        git_tags: run.git_tags.clone(),
3201        git_nearest_tag: run.git_nearest_tag.clone(),
3202        git_commit_date: run.git_commit_date,
3203    })
3204}
3205
3206/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3207/// Returns the number of newly linked entries.
3208fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3209    let mut linked = 0usize;
3210    for json_path in collect_result_json_candidates(folder) {
3211        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3212            continue;
3213        };
3214        if is_dir_already_registered(reg, &parent) {
3215            continue;
3216        }
3217        let Some(entry) = build_registry_entry_from_json(json_path) else {
3218            continue;
3219        };
3220        reg.add_entry(entry);
3221        linked += 1;
3222    }
3223    linked
3224}
3225
3226/// Scan all watched directories (plus the default output root) into `reg`.
3227async fn auto_scan_watched_dirs(state: &AppState) {
3228    let dirs: Vec<PathBuf> = {
3229        let wd = state.watched_dirs.lock().await;
3230        wd.dirs.clone()
3231    };
3232    if dirs.is_empty() {
3233        return;
3234    }
3235    let mut reg = state.registry.lock().await;
3236    let mut total = 0usize;
3237    for dir in &dirs {
3238        if dir.is_dir() {
3239            total += scan_folder_into_registry(dir, &mut reg);
3240        }
3241    }
3242    if total > 0 {
3243        let _ = reg.save(&state.registry_path);
3244    }
3245}
3246
3247// ── Watched-dir route forms ───────────────────────────────────────────────────
3248
3249#[derive(Deserialize)]
3250struct WatchedDirForm {
3251    folder_path: String,
3252    #[serde(default = "default_redirect")]
3253    redirect_to: String,
3254}
3255
3256fn default_redirect() -> String {
3257    "/view-reports".to_string()
3258}
3259
3260#[derive(Deserialize)]
3261struct WatchedDirRefreshForm {
3262    #[serde(default = "default_redirect")]
3263    redirect_to: String,
3264}
3265
3266// ── Watched-dir helpers ───────────────────────────────────────────────────────
3267
3268/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3269fn safe_redirect(dest: &str) -> &str {
3270    if dest.starts_with('/') {
3271        dest
3272    } else {
3273        "/"
3274    }
3275}
3276
3277// ── Watched-dir handlers ──────────────────────────────────────────────────────
3278
3279async fn add_watched_dir_handler(
3280    State(state): State<AppState>,
3281    Form(form): Form<WatchedDirForm>,
3282) -> impl IntoResponse {
3283    if state.server_mode {
3284        return StatusCode::NOT_FOUND.into_response();
3285    }
3286    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3287        strip_unc_prefix(p)
3288    } else {
3289        let dest = format!(
3290            "{}?error=Folder+not+found+or+path+is+invalid.",
3291            safe_redirect(&form.redirect_to)
3292        );
3293        return axum::response::Redirect::to(&dest).into_response();
3294    };
3295    if !folder.is_dir() {
3296        let dest = format!(
3297            "{}?error=Selected+path+is+not+a+directory.",
3298            safe_redirect(&form.redirect_to)
3299        );
3300        return axum::response::Redirect::to(&dest).into_response();
3301    }
3302
3303    // Persist the watched directory.
3304    {
3305        let mut wd = state.watched_dirs.lock().await;
3306        wd.add(folder.clone());
3307        let _ = wd.save(&state.watched_dirs_path);
3308    }
3309
3310    // Immediately scan the folder and add any new reports.
3311    let linked = {
3312        let mut reg = state.registry.lock().await;
3313        let n = scan_folder_into_registry(&folder, &mut reg);
3314        if n > 0 {
3315            let _ = reg.save(&state.registry_path);
3316        }
3317        n
3318    };
3319
3320    let dest = if linked > 0 {
3321        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3322    } else {
3323        format!(
3324            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3325            safe_redirect(&form.redirect_to)
3326        )
3327    };
3328    axum::response::Redirect::to(&dest).into_response()
3329}
3330
3331async fn remove_watched_dir_handler(
3332    State(state): State<AppState>,
3333    Form(form): Form<WatchedDirForm>,
3334) -> impl IntoResponse {
3335    if state.server_mode {
3336        return StatusCode::NOT_FOUND.into_response();
3337    }
3338    let folder = PathBuf::from(&form.folder_path);
3339    {
3340        let mut wd = state.watched_dirs.lock().await;
3341        wd.remove(&folder);
3342        let _ = wd.save(&state.watched_dirs_path);
3343    }
3344    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3345}
3346
3347async fn refresh_watched_dirs_handler(
3348    State(state): State<AppState>,
3349    Form(form): Form<WatchedDirRefreshForm>,
3350) -> impl IntoResponse {
3351    if state.server_mode {
3352        return StatusCode::NOT_FOUND.into_response();
3353    }
3354    let dirs: Vec<PathBuf> = {
3355        let wd = state.watched_dirs.lock().await;
3356        wd.dirs.clone()
3357    };
3358    let mut total = 0usize;
3359    {
3360        let mut reg = state.registry.lock().await;
3361        for dir in &dirs {
3362            if dir.is_dir() {
3363                total += scan_folder_into_registry(dir, &mut reg);
3364            }
3365        }
3366        if total > 0 {
3367            let _ = reg.save(&state.registry_path);
3368        }
3369    }
3370    let dest = if total > 0 {
3371        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3372    } else {
3373        safe_redirect(&form.redirect_to).to_owned()
3374    };
3375    axum::response::Redirect::to(&dest).into_response()
3376}
3377
3378#[derive(Debug, Deserialize)]
3379struct OpenPathQuery {
3380    path: Option<String>,
3381}
3382
3383fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3384    let mut ancestor = std::path::Path::new(raw);
3385    loop {
3386        match ancestor.parent() {
3387            Some(p) => {
3388                ancestor = p;
3389                if ancestor.is_dir() {
3390                    break;
3391                }
3392            }
3393            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3394        }
3395    }
3396    Ok(ancestor.to_path_buf())
3397}
3398
3399async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3400    match tokio::fs::canonicalize(raw).await {
3401        Ok(canonical) if canonical.is_file() => match canonical.parent() {
3402            Some(p) => Ok(p.to_path_buf()),
3403            None => Err((StatusCode::BAD_REQUEST, "path has no parent")),
3404        },
3405        Ok(canonical) if canonical.is_dir() => Ok(canonical),
3406        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3407        Err(_) => find_existing_ancestor(raw),
3408    }
3409}
3410
3411async fn open_path_handler(
3412    State(state): State<AppState>,
3413    Query(query): Query<OpenPathQuery>,
3414) -> impl IntoResponse {
3415    if state.server_mode {
3416        return Json(serde_json::json!({
3417            "server_mode_disabled": true,
3418            "message": "Opening a path in the file manager is only available in local desktop mode."
3419        }))
3420        .into_response();
3421    }
3422    // Skip the OS file-manager call in headless / CI environments.
3423    if std::env::var("SLOC_HEADLESS").is_ok() {
3424        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3425    }
3426    let raw = match query.path.as_deref() {
3427        Some(p) if !p.is_empty() => p,
3428        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3429    };
3430
3431    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
3432    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
3433    // so the file explorer still opens somewhere useful.
3434    let target = match resolve_open_target(raw).await {
3435        Ok(p) => p,
3436        Err((code, msg)) => return (code, msg).into_response(),
3437    };
3438
3439    #[cfg(target_os = "windows")]
3440    win_dialog_focus::open_folder_foreground(target);
3441    #[cfg(target_os = "macos")]
3442    let _ = std::process::Command::new("open")
3443        .arg(&target)
3444        .stdout(Stdio::null())
3445        .stderr(Stdio::null())
3446        .spawn();
3447    #[cfg(target_os = "linux")]
3448    {
3449        let folder_name = target
3450            .file_name()
3451            .and_then(|n| n.to_str())
3452            .map(str::to_owned);
3453        let _ = std::process::Command::new("xdg-open")
3454            .arg(&target)
3455            .stdout(Stdio::null())
3456            .stderr(Stdio::null())
3457            .spawn();
3458        // Best-effort: raise the file manager window once it appears.
3459        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
3460        // installed; failures are silently discarded.
3461        if let Some(name) = folder_name {
3462            std::thread::spawn(move || {
3463                std::thread::sleep(std::time::Duration::from_millis(800));
3464                let _ = std::process::Command::new("wmctrl")
3465                    .args(["-a", &name])
3466                    .stdout(Stdio::null())
3467                    .stderr(Stdio::null())
3468                    .spawn();
3469            });
3470        }
3471    }
3472
3473    Json(serde_json::json!({"ok": true})).into_response()
3474}
3475
3476async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3477    let (content_type, bytes): (&'static str, &'static [u8]) =
3478        match (folder.as_str(), file.as_str()) {
3479            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3480            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3481            ("icons", "c.png") => ("image/png", IMG_ICON_C),
3482            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3483            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3484            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3485            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3486            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3487            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3488            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3489            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3490            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3491            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3492            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3493            ("icons", "r.png") => ("image/png", IMG_ICON_R),
3494            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3495            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3496            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3497            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3498            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3499            _ => return StatusCode::NOT_FOUND.into_response(),
3500        };
3501    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3502}
3503
3504async fn preview_handler(
3505    State(state): State<AppState>,
3506    Query(query): Query<PreviewQuery>,
3507) -> impl IntoResponse {
3508    let raw_path = query
3509        .path
3510        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3511    let resolved = resolve_input_path(&raw_path);
3512
3513    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
3514    // binary whose working directory is not the project root), return a clear message
3515    // instead of an opaque OS error from build_preview_html.
3516    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3517        return Html(
3518            r#"<div class="preview-error">Sample directory not available on this server.
3519            Enter a path to a project directory or upload files using Browse.</div>"#
3520                .to_string(),
3521        );
3522    }
3523
3524    if state.server_mode {
3525        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3526        // Upload temp dirs and built-in sample/fixture paths are always safe to preview.
3527        if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3528            let config = &state.base_config;
3529            if config.discovery.allowed_scan_roots.is_empty() {
3530                return Html(
3531                    r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3532                );
3533            }
3534            let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3535                fs::canonicalize(root)
3536                    .ok()
3537                    .is_some_and(|r| canonical.starts_with(&r))
3538            });
3539            if !allowed {
3540                return Html(
3541                    r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3542                );
3543            }
3544        }
3545    }
3546
3547    let include_patterns = split_patterns(query.include_globs.as_deref());
3548    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3549
3550    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3551        Ok(html) => Html(html),
3552        Err(err) => Html(format!(
3553            r#"<div class="preview-error">Preview failed: {}</div>"#,
3554            escape_html(&err.to_string())
3555        )),
3556    }
3557}
3558
3559#[derive(Debug, Deserialize, Default)]
3560struct SuggestCoverageQuery {
3561    path: Option<String>,
3562}
3563
3564#[derive(Serialize)]
3565struct SuggestCoverageResponse {
3566    found: Option<String>,
3567    tool: Option<&'static str>,
3568    hint: Option<&'static str>,
3569}
3570
3571async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3572    const CANDIDATES: &[&str] = &[
3573        // LCOV — cargo-llvm-cov, gcov, lcov
3574        "coverage/lcov.info",
3575        "lcov.info",
3576        "target/llvm-cov/lcov.info",
3577        "target/coverage/lcov.info",
3578        "target/debug/coverage/lcov.info",
3579        "coverage/coverage.lcov",
3580        "build/coverage/lcov.info",
3581        "reports/lcov.info",
3582        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
3583        "coverage.xml",
3584        "coverage/coverage.xml",
3585        "target/site/cobertura/coverage.xml",
3586        "build/reports/coverage/coverage.xml",
3587        // JaCoCo XML — Gradle, Maven JaCoCo plugin
3588        "target/site/jacoco/jacoco.xml",
3589        "build/reports/jacoco/test/jacocoTestReport.xml",
3590        "build/reports/jacoco/jacocoTestReport.xml",
3591        "build/jacoco/jacoco.xml",
3592    ];
3593    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3594    let found = CANDIDATES
3595        .iter()
3596        .map(|rel| root.join(rel))
3597        .find(|p| p.is_file())
3598        .map(|p| display_path(&p));
3599
3600    let (tool, hint) = detect_coverage_tool(&root);
3601    Json(SuggestCoverageResponse { found, tool, hint })
3602}
3603
3604/// Inspect the project root for known build/package files and return the most likely coverage
3605/// tool name and the shell command needed to generate a coverage file.
3606fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3607    if root.join("Cargo.toml").is_file() {
3608        return (
3609            Some("cargo-llvm-cov"),
3610            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3611        );
3612    }
3613    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3614        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3615    }
3616    if root.join("pom.xml").is_file() {
3617        return (Some("jacoco"), Some("mvn test jacoco:report"));
3618    }
3619    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3620        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3621    }
3622    (None, None)
3623}
3624
3625/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
3626#[allow(clippy::result_large_err)]
3627fn validate_server_scan_path(
3628    config: &sloc_config::AppConfig,
3629    resolved_path: &Path,
3630    csp_nonce: &str,
3631) -> Result<(), Response> {
3632    if config.discovery.allowed_scan_roots.is_empty() {
3633        let template = ErrorTemplate {
3634            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3635                      Set allowed_scan_roots in the server config to permit scanning."
3636                .to_string(),
3637            last_report_url: None,
3638            last_report_label: None,
3639            run_id: None,
3640            error_code: Some(403),
3641            csp_nonce: csp_nonce.to_owned(),
3642            version: env!("CARGO_PKG_VERSION"),
3643        };
3644        return Err((
3645            StatusCode::FORBIDDEN,
3646            Html(
3647                template
3648                    .render()
3649                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3650            ),
3651        )
3652            .into_response());
3653    }
3654    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3655    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3656        fs::canonicalize(root)
3657            .ok()
3658            .is_some_and(|r| canonical.starts_with(&r))
3659    });
3660    if !allowed {
3661        tracing::warn!(event = "path_rejected", path = %canonical.display(),
3662            "Scan path not in allowed_scan_roots");
3663        let template = ErrorTemplate {
3664            message: "The requested path is not within an allowed scan directory.".to_string(),
3665            last_report_url: None,
3666            last_report_label: None,
3667            run_id: None,
3668            error_code: Some(403),
3669            csp_nonce: csp_nonce.to_owned(),
3670            version: env!("CARGO_PKG_VERSION"),
3671        };
3672        return Err((
3673            StatusCode::FORBIDDEN,
3674            Html(
3675                template
3676                    .render()
3677                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3678            ),
3679        )
3680            .into_response());
3681    }
3682    Ok(())
3683}
3684
3685/// Exclude the output directory from scanning so artifacts don't pollute counts.
3686fn apply_output_dir_exclusions(
3687    config: &mut sloc_config::AppConfig,
3688    project_path: &str,
3689    raw_output_dir: &str,
3690) {
3691    let project_root = resolve_input_path(project_path);
3692    let raw_out = raw_output_dir.trim();
3693    let resolved_out = if raw_out.is_empty() {
3694        project_root.join("sloc")
3695    } else if Path::new(raw_out).is_absolute() {
3696        PathBuf::from(raw_out)
3697    } else {
3698        workspace_root().join(raw_out)
3699    };
3700    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3701        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3702            let dir = first.to_string();
3703            if !config.discovery.excluded_directories.contains(&dir) {
3704                config.discovery.excluded_directories.push(dir);
3705            }
3706        }
3707    }
3708    if !config
3709        .discovery
3710        .excluded_directories
3711        .iter()
3712        .any(|d| d == "sloc")
3713    {
3714        config
3715            .discovery
3716            .excluded_directories
3717            .push("sloc".to_string());
3718    }
3719}
3720
3721/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
3722const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3723    ScanSummarySnapshot {
3724        files_analyzed: run.summary_totals.files_analyzed,
3725        files_skipped: run.summary_totals.files_skipped,
3726        total_physical_lines: run.summary_totals.total_physical_lines,
3727        code_lines: run.summary_totals.code_lines,
3728        comment_lines: run.summary_totals.comment_lines,
3729        blank_lines: run.summary_totals.blank_lines,
3730        functions: run.summary_totals.functions,
3731        classes: run.summary_totals.classes,
3732        variables: run.summary_totals.variables,
3733        imports: run.summary_totals.imports,
3734        test_count: run.summary_totals.test_count,
3735        coverage_lines_found: run.summary_totals.coverage_lines_found,
3736        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3737        coverage_functions_found: run.summary_totals.coverage_functions_found,
3738        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3739        coverage_branches_found: run.summary_totals.coverage_branches_found,
3740        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3741    }
3742}
3743
3744/// Build the `RegistryEntry` for the just-completed scan run.
3745pub(crate) fn build_run_registry_entry(
3746    run: &AnalysisRun,
3747    run_id: &str,
3748    project_label: &str,
3749    artifacts: &RunArtifacts,
3750) -> RegistryEntry {
3751    RegistryEntry {
3752        run_id: run_id.to_owned(),
3753        timestamp_utc: run.tool.timestamp_utc,
3754        project_label: project_label.to_owned(),
3755        input_roots: run.input_roots.clone(),
3756        json_path: artifacts.json_path.clone(),
3757        html_path: artifacts.html_path.clone(),
3758        pdf_path: artifacts.pdf_path.clone(),
3759        csv_path: artifacts.csv_path.clone(),
3760        xlsx_path: artifacts.xlsx_path.clone(),
3761        summary: summary_snapshot_from_run(run),
3762        git_branch: run.git_branch.clone(),
3763        git_commit: run.git_commit_short.clone(),
3764        git_author: run.git_commit_author.clone(),
3765        git_tags: run.git_tags.clone(),
3766        git_nearest_tag: run.git_nearest_tag.clone(),
3767        git_commit_date: run.git_commit_date.clone(),
3768    }
3769}
3770
3771/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
3772fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3773    if let Some(policy) = form.mixed_line_policy {
3774        config.analysis.mixed_line_policy = policy;
3775    }
3776    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3777    config.analysis.generated_file_detection =
3778        form.generated_file_detection.as_deref() != Some("disabled");
3779    config.analysis.minified_file_detection =
3780        form.minified_file_detection.as_deref() != Some("disabled");
3781    config.analysis.vendor_directory_detection =
3782        form.vendor_directory_detection.as_deref() != Some("disabled");
3783    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3784    if let Some(binary_behavior) = form.binary_file_behavior {
3785        config.analysis.binary_file_behavior = binary_behavior;
3786    }
3787    apply_report_opts(config, form);
3788    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3789    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3790    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3791    if let Some(policy) = form.continuation_line_policy {
3792        config.analysis.continuation_line_policy = policy;
3793    }
3794    if let Some(policy) = form.blank_in_block_comment_policy {
3795        config.analysis.blank_in_block_comment_policy = policy;
3796    }
3797    config.analysis.count_compiler_directives =
3798        form.count_compiler_directives.as_deref() != Some("disabled");
3799    apply_style_threshold(config, form);
3800    apply_coverage_path(config, form);
3801}
3802
3803fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3804    if let Some(report_title) = form.report_title.as_deref() {
3805        let trimmed = report_title.trim();
3806        if !trimmed.is_empty() {
3807            config.reporting.report_title = trimmed.to_string();
3808        }
3809    }
3810    if let Some(hf) = form.report_header_footer.as_deref() {
3811        let trimmed = hf.trim();
3812        config.reporting.report_header_footer = if trimmed.is_empty() {
3813            None
3814        } else {
3815            Some(trimmed.to_string())
3816        };
3817    }
3818}
3819
3820fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3821    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3822        if let Ok(t) = threshold_str.parse::<u16>() {
3823            if t == 80 || t == 100 || t == 120 {
3824                config.analysis.style_col_threshold = t;
3825            }
3826        }
3827    }
3828    if let Some(v) = form.style_analysis_enabled.as_deref() {
3829        config.analysis.style_analysis_enabled = v != "disabled";
3830    }
3831    if let Some(v) = form.style_score_threshold.as_deref() {
3832        if let Ok(t) = v.parse::<u8>() {
3833            config.analysis.style_score_threshold = t.min(100);
3834        }
3835    }
3836    if let Some(v) = form.style_lang_scope.as_deref() {
3837        let scope = v.trim();
3838        if scope == "c_family" || scope == "all" {
3839            config.analysis.style_lang_scope = scope.to_string();
3840        }
3841    }
3842}
3843
3844fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3845    if let Some(cov) = &form.coverage_file {
3846        let trimmed = cov.trim();
3847        if !trimmed.is_empty() {
3848            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
3849        }
3850    }
3851}
3852
3853/// Fire-and-forget: generate the PDF in a background task if one is pending.
3854/// On failure, clears `pdf_path` in the artifacts map so the results page shows
3855/// an error instead of spinning indefinitely.
3856fn spawn_pdf_background(
3857    pending_pdf: PendingPdf,
3858    run_id: String,
3859    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3860) {
3861    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
3862        tokio::spawn(async move {
3863            let result = tokio::task::spawn_blocking(move || {
3864                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
3865                if cleanup_src {
3866                    let _ = fs::remove_file(&pdf_src);
3867                }
3868                r
3869            })
3870            .await;
3871            let failed = match result {
3872                Ok(Ok(())) => false,
3873                Ok(Err(err)) => {
3874                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
3875                    true
3876                }
3877                Err(err) => {
3878                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
3879                    true
3880                }
3881            };
3882            if failed {
3883                let mut map = artifacts.lock().await;
3884                if let Some(entry) = map.get_mut(&run_id) {
3885                    entry.pdf_path = None;
3886                }
3887            }
3888        });
3889    }
3890}
3891
3892/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
3893/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
3894/// result page can show an error on the next visit instead of spinning indefinitely.
3895fn spawn_native_pdf_background(
3896    json_path: PathBuf,
3897    pdf_dest: PathBuf,
3898    run_id: String,
3899    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
3900) {
3901    tokio::spawn(async move {
3902        let result = tokio::task::spawn_blocking(move || {
3903            let run = sloc_core::read_json(&json_path)?;
3904            write_pdf_from_run(&run, &pdf_dest)
3905        })
3906        .await;
3907        let failed = match result {
3908            Ok(Ok(())) => false,
3909            Ok(Err(err)) => {
3910                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
3911                true
3912            }
3913            Err(err) => {
3914                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
3915                true
3916            }
3917        };
3918        if failed {
3919            let mut map = artifacts.lock().await;
3920            if let Some(entry) = map.get_mut(&run_id) {
3921                entry.pdf_path = None;
3922            }
3923        }
3924    });
3925}
3926
3927/// Sum the code lines added in this comparison (new + grown files).
3928fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3929    cmp.file_deltas
3930        .iter()
3931        .map(|f| match f.status {
3932            FileChangeStatus::Added => f.current_code,
3933            FileChangeStatus::Modified => f.code_delta.max(0),
3934            _ => 0,
3935        })
3936        .sum()
3937}
3938
3939/// Sum the code lines removed in this comparison (deleted + shrunk files).
3940fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3941    cmp.file_deltas
3942        .iter()
3943        .map(|f| match f.status {
3944            FileChangeStatus::Removed => f.baseline_code,
3945            FileChangeStatus::Modified => (-f.code_delta).max(0),
3946            _ => 0,
3947        })
3948        .sum()
3949}
3950
3951/// Sum the code lines present in both scans without any change (Unchanged files).
3952fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
3953    cmp.file_deltas
3954        .iter()
3955        .filter(|f| f.status == FileChangeStatus::Unchanged)
3956        .map(|f| f.current_code)
3957        .sum()
3958}
3959
3960/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
3961fn build_submodule_row(
3962    s: &sloc_core::SubmoduleSummary,
3963    run: &AnalysisRun,
3964    run_id: &str,
3965    run_dir: &Path,
3966) -> SubmoduleRow {
3967    let safe = sanitize_project_label(&s.name);
3968    let artifact_key = format!("sub_{safe}");
3969    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
3970        let parent_path = run
3971            .input_roots
3972            .first()
3973            .map_or("", std::string::String::as_str);
3974        let sub_run = build_sub_run(run, s, parent_path);
3975        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
3976            let sub_dir = run_dir.join("submodules");
3977            let _ = fs::create_dir_all(&sub_dir);
3978            let path = sub_dir.join(format!("{artifact_key}.html"));
3979            if fs::write(&path, sub_html.as_bytes()).is_ok() {
3980                Some(format!("/runs/{artifact_key}/{run_id}"))
3981            } else {
3982                None
3983            }
3984        })
3985    } else {
3986        None
3987    };
3988    SubmoduleRow {
3989        name: s.name.clone(),
3990        relative_path: s.relative_path.clone(),
3991        files_analyzed: s.files_analyzed,
3992        code_lines: s.code_lines,
3993        comment_lines: s.comment_lines,
3994        blank_lines: s.blank_lines,
3995        total_physical_lines: s.total_physical_lines,
3996        html_url,
3997    }
3998}
3999
4000// Immediately returns a wait page and runs the analysis in a background tokio task.
4001// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4002#[allow(clippy::similar_names)]
4003#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4004async fn analyze_handler(
4005    State(state): State<AppState>,
4006    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4007    Form(form): Form<AnalyzeForm>,
4008) -> impl IntoResponse {
4009    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4010        let template = ErrorTemplate {
4011            message: format!(
4012                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4013             Please wait a moment and try again."
4014            ),
4015            last_report_url: None,
4016            last_report_label: None,
4017            run_id: None,
4018            error_code: Some(503),
4019            csp_nonce: csp_nonce.clone(),
4020            version: env!("CARGO_PKG_VERSION"),
4021        };
4022        return (
4023            StatusCode::SERVICE_UNAVAILABLE,
4024            Html(
4025                template
4026                    .render()
4027                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4028            ),
4029        )
4030            .into_response();
4031    };
4032
4033    let mut config = state.base_config.clone();
4034
4035    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4036    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4037    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4038
4039    if !is_git_mode {
4040        let resolved_path = resolve_input_path(&form.path);
4041        if state.server_mode
4042            && !is_upload_tmp_path(&resolved_path)
4043            && !is_sample_path(&resolved_path)
4044        {
4045            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4046                return resp;
4047            }
4048        }
4049        config.discovery.root_paths = vec![resolved_path];
4050    }
4051
4052    apply_form_to_config(&mut config, &form);
4053    apply_output_dir_exclusions(
4054        &mut config,
4055        &form.path,
4056        form.output_dir.as_deref().unwrap_or(""),
4057    );
4058
4059    // Generate a wait_id now (before spawning) so the client can poll for status.
4060    let wait_id = uuid::Uuid::new_v4().to_string();
4061    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4062
4063    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4064    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4065    let task_cancel = Arc::clone(&cancel_token);
4066
4067    // Phase tracker: updated by run_analysis_task at key checkpoints.
4068    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4069    let task_phase = Arc::clone(&phase);
4070
4071    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4072    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4073    let task_files_done = Arc::clone(&files_done);
4074    let task_files_total = Arc::clone(&files_total);
4075
4076    // Register Running state before building the task struct so the semaphore permit
4077    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4078    {
4079        let mut runs = state.async_runs.lock().await;
4080        runs.insert(
4081            wait_id.clone(),
4082            AsyncRunState::Running {
4083                started_at: std::time::Instant::now(),
4084                cancel_token,
4085                phase,
4086                files_done,
4087                files_total,
4088            },
4089        );
4090    }
4091
4092    let task = AnalysisTask {
4093        sem_permit,
4094        state: state.clone(),
4095        wait_id: wait_id.clone(),
4096        config,
4097        cancel: task_cancel,
4098        phase: task_phase,
4099        files_done: task_files_done,
4100        files_total: task_files_total,
4101        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4102        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4103        project_path: form.path.clone(),
4104        // In server mode the client-supplied output_dir is ignored — artifacts are
4105        // always written under the server's configured output root so remote users
4106        // cannot direct writes to arbitrary filesystem paths.
4107        output_dir: if state.server_mode {
4108            None
4109        } else {
4110            form.output_dir.clone()
4111        },
4112        clones_dir: state.git_clones_dir.clone(),
4113    };
4114
4115    tokio::spawn(run_analysis_task(task));
4116
4117    let template = ScanWaitTemplate {
4118        version: env!("CARGO_PKG_VERSION"),
4119        wait_id_json,
4120        project_path: form.path.clone(),
4121        csp_nonce,
4122    };
4123    let html = template
4124        .render()
4125        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4126    let mut response = Html(html).into_response();
4127    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4128        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4129            response.headers_mut().insert(name, val);
4130        }
4131    }
4132    response
4133}
4134
4135struct AnalysisTask {
4136    sem_permit: tokio::sync::OwnedSemaphorePermit,
4137    state: AppState,
4138    wait_id: String,
4139    config: AppConfig,
4140    cancel: Arc<std::sync::atomic::AtomicBool>,
4141    phase: Arc<std::sync::Mutex<String>>,
4142    files_done: Arc<std::sync::atomic::AtomicUsize>,
4143    files_total: Arc<std::sync::atomic::AtomicUsize>,
4144    git_repo: Option<String>,
4145    git_ref: Option<String>,
4146    project_path: String,
4147    output_dir: Option<String>,
4148    clones_dir: PathBuf,
4149}
4150
4151#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4152async fn run_analysis_task(task: AnalysisTask) {
4153    let _permit = task.sem_permit;
4154
4155    let cancel_sb = Arc::clone(&task.cancel);
4156    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4157    let clones_dir_sb = task.clones_dir;
4158    // Save the upload staging path before config is moved into spawn_blocking.
4159    let upload_staging_root = task
4160        .config
4161        .discovery
4162        .root_paths
4163        .first()
4164        .filter(|p| is_upload_tmp_path(p))
4165        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4166        .map(PathBuf::from);
4167    let config_sb = task.config;
4168    let progress_sb = sloc_core::ProgressCounters {
4169        files_done: Arc::clone(&task.files_done),
4170        files_total: Arc::clone(&task.files_total),
4171    };
4172    if let Ok(mut p) = task.phase.lock() {
4173        *p = "Scanning files".to_string();
4174    }
4175    let analysis_result = tokio::task::spawn_blocking(move || {
4176        run_analysis_blocking(
4177            config_sb,
4178            git_repo_sb,
4179            git_ref_sb,
4180            clones_dir_sb,
4181            cancel_sb,
4182            Some(progress_sb),
4183        )
4184    })
4185    .await
4186    .map_err(|err| anyhow::anyhow!(err.to_string()))
4187    .and_then(|result| result);
4188
4189    if let Ok(mut p) = task.phase.lock() {
4190        *p = "Writing reports".to_string();
4191    }
4192
4193    // If cancelled while running, discard results and mark as cancelled.
4194    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4195        let mut runs = task.state.async_runs.lock().await;
4196        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4197        if matches!(
4198            runs.get(&task.wait_id),
4199            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4200        ) {
4201            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4202        }
4203        drop(runs);
4204        return;
4205    }
4206
4207    let run = match analysis_result {
4208        Ok(v) => v,
4209        Err(err) => {
4210            // Distinguish user-cancelled from real failure.
4211            if err.to_string().contains("analysis cancelled") {
4212                let mut runs = task.state.async_runs.lock().await;
4213                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4214                drop(runs);
4215                return;
4216            }
4217            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4218            let mut runs = task.state.async_runs.lock().await;
4219            runs.insert(
4220                task.wait_id.clone(),
4221                AsyncRunState::Failed {
4222                    message: "Analysis failed. Check that the path exists and is readable."
4223                        .to_string(),
4224                },
4225            );
4226            drop(runs);
4227            return;
4228        }
4229    };
4230
4231    let run_id = run.tool.run_id.clone();
4232    tracing::info!(event = "scan_complete", run_id = %run_id,
4233        path = %task.project_path, files = run.summary_totals.files_analyzed,
4234        "Analysis finished");
4235
4236    let prev_entry: Option<RegistryEntry> = {
4237        let reg = task.state.registry.lock().await;
4238        reg.entries_for_roots(&run.input_roots)
4239            .into_iter()
4240            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4241            .cloned()
4242    };
4243
4244    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4245        prev.json_path
4246            .as_ref()
4247            .and_then(|p| read_json(p).ok())
4248            .map(|prev_run| compute_delta(&prev_run, &run))
4249    });
4250    let prev_scan_count: usize = {
4251        let reg = task.state.registry.lock().await;
4252        reg.entries_for_roots(&run.input_roots)
4253            .iter()
4254            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4255            .count()
4256    };
4257
4258    // Build the HTML report now that delta is available, so the artifact
4259    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
4260    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4261        .as_ref()
4262        .zip(prev_entry.as_ref())
4263        .map(|(cmp, prev)| ReportDeltaContext {
4264            delta_code_added: sum_added_code_lines(cmp),
4265            delta_code_removed: sum_removed_code_lines(cmp),
4266            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4267            delta_files_added: cmp.files_added,
4268            delta_files_removed: cmp.files_removed,
4269            delta_files_modified: cmp.files_modified,
4270            delta_files_unchanged: cmp.files_unchanged,
4271            prev_code_lines: prev.summary.code_lines,
4272            prev_scan_count: prev_scan_count + 1,
4273            prev_scan_label: fmt_la_time(prev.timestamp_utc),
4274            prev_run_id: Some(prev.run_id.clone()),
4275            current_run_id: Some(run_id.clone()),
4276        });
4277    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4278        Ok(h) => h,
4279        Err(err) => {
4280            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4281            let mut runs = task.state.async_runs.lock().await;
4282            runs.insert(
4283                task.wait_id.clone(),
4284                AsyncRunState::Failed {
4285                    message: "Failed to render HTML report.".to_string(),
4286                },
4287            );
4288            drop(runs);
4289            return;
4290        }
4291    };
4292
4293    let output_root = resolve_output_root(task.output_dir.as_deref());
4294    let project_label = derive_project_label(
4295        task.git_repo.as_deref(),
4296        task.git_ref.as_deref(),
4297        &task.project_path,
4298    );
4299    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4300    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4301
4302    let result_context = RunResultContext {
4303        prev_entry: prev_entry.clone(),
4304        prev_scan_count,
4305        project_path: task.project_path.clone(),
4306    };
4307
4308    let artifact_result = persist_run_artifacts(
4309        &run,
4310        &report_html,
4311        &run_dir,
4312        &run.effective_configuration.reporting.report_title,
4313        &file_stem,
4314        result_context,
4315    );
4316
4317    let (artifacts, pending_pdf) = match artifact_result {
4318        Ok(v) => v,
4319        Err(err) => {
4320            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4321            let mut runs = task.state.async_runs.lock().await;
4322            runs.insert(
4323                task.wait_id.clone(),
4324                AsyncRunState::Failed {
4325                    message: "Failed to save report artifacts. Check available disk space."
4326                        .to_string(),
4327                },
4328            );
4329            drop(runs);
4330            return;
4331        }
4332    };
4333
4334    {
4335        let mut map = task.state.artifacts.lock().await;
4336        map.insert(run_id.clone(), artifacts.clone());
4337    }
4338
4339    {
4340        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4341        let mut reg = task.state.registry.lock().await;
4342        reg.add_entry(entry);
4343        let _ = reg.save(&task.state.registry_path);
4344    }
4345
4346    if let Some(ref cfg_path) = artifacts.scan_config_path {
4347        save_scan_config_json(
4348            cfg_path,
4349            &run,
4350            &task.project_path,
4351            task.output_dir.as_deref(),
4352        );
4353    }
4354
4355    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4356
4357    prom_runs_total().inc();
4358
4359    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
4360    let mut runs = task.state.async_runs.lock().await;
4361    runs.insert(
4362        task.wait_id.clone(),
4363        AsyncRunState::Complete {
4364            run_id: run_id.clone(),
4365        },
4366    );
4367    drop(runs);
4368
4369    // Remove the client-upload staging directory after a successful scan so
4370    // that uploaded project files don't accumulate in the OS temp directory.
4371    if let Some(staging) = upload_staging_root {
4372        let _ = tokio::fs::remove_dir_all(staging).await;
4373    }
4374
4375    let _ = scan_delta;
4376}
4377
4378fn save_scan_config_json(
4379    cfg_path: &std::path::Path,
4380    run: &sloc_core::AnalysisRun,
4381    project_path: &str,
4382    output_dir: Option<&str>,
4383) {
4384    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4385        .ok()
4386        .and_then(|v| v.as_str().map(String::from))
4387        .unwrap_or_else(|| "code_only".to_string());
4388    let behavior_str =
4389        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4390            .ok()
4391            .and_then(|v| v.as_str().map(String::from))
4392            .unwrap_or_else(|| "skip".to_string());
4393    let scan_cfg = ScanConfig {
4394        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4395        path: project_path.to_string(),
4396        include_globs: run
4397            .effective_configuration
4398            .discovery
4399            .include_globs
4400            .join("\n"),
4401        exclude_globs: run
4402            .effective_configuration
4403            .discovery
4404            .exclude_globs
4405            .join("\n"),
4406        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4407        mixed_line_policy: policy_str,
4408        python_docstrings_as_comments: run
4409            .effective_configuration
4410            .analysis
4411            .python_docstrings_as_comments,
4412        generated_file_detection: run
4413            .effective_configuration
4414            .analysis
4415            .generated_file_detection,
4416        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4417        vendor_directory_detection: run
4418            .effective_configuration
4419            .analysis
4420            .vendor_directory_detection,
4421        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4422        binary_file_behavior: behavior_str,
4423        output_dir: output_dir.unwrap_or("").to_string(),
4424        report_title: run.effective_configuration.reporting.report_title.clone(),
4425    };
4426    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4427        let _ = std::fs::write(cfg_path, json);
4428    }
4429}
4430
4431#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
4432fn run_analysis_blocking(
4433    mut config: AppConfig,
4434    git_repo: Option<String>,
4435    git_ref: Option<String>,
4436    clones_dir: PathBuf,
4437    cancel: Arc<std::sync::atomic::AtomicBool>,
4438    progress: Option<sloc_core::ProgressCounters>,
4439) -> Result<sloc_core::AnalysisRun> {
4440    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4441        let dest = git_clone_dest(&repo, &clones_dir);
4442        sloc_git::clone_or_fetch(&repo, &dest)?;
4443        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4444        sloc_git::create_worktree(&dest, &refname, &wt)?;
4445        config.discovery.root_paths = vec![wt.clone()];
4446        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4447        let _ = sloc_git::destroy_worktree(&dest, &wt);
4448        let mut run = run?;
4449        if run.git_branch.is_none() {
4450            run.git_branch = Some(refname);
4451        }
4452        return Ok(run);
4453    }
4454    analyze(&config, "serve", Some(&cancel), progress.as_ref())
4455}
4456
4457fn derive_project_label(
4458    git_repo: Option<&str>,
4459    git_ref: Option<&str>,
4460    fallback_path: &str,
4461) -> String {
4462    match (
4463        git_repo.filter(|s| !s.is_empty()),
4464        git_ref.filter(|s| !s.is_empty()),
4465    ) {
4466        (Some(repo), Some(refname)) => {
4467            let repo_name = repo
4468                .trim_end_matches('/')
4469                .trim_end_matches(".git")
4470                .rsplit('/')
4471                .next()
4472                .unwrap_or("repo");
4473            sanitize_project_label(&format!("{repo_name}_{refname}"))
4474        }
4475        _ => sanitize_project_label(fallback_path),
4476    }
4477}
4478
4479fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4480    let commit = commit_short.unwrap_or("").trim();
4481    if commit.is_empty() {
4482        project_label.to_string()
4483    } else {
4484        format!("{project_label}_{commit}")
4485    }
4486}
4487
4488// ── Async scan status + result handlers ──────────────────────────────────────
4489
4490#[derive(Serialize)]
4491#[serde(tag = "state", rename_all = "snake_case")]
4492enum AsyncRunStatusResponse {
4493    Running {
4494        elapsed_secs: u64,
4495        phase: String,
4496        files_done: u64,
4497        files_total: u64,
4498    },
4499    Complete {
4500        run_id: String,
4501    },
4502    Failed {
4503        message: String,
4504    },
4505    Cancelled,
4506}
4507
4508async fn async_run_status_handler(
4509    State(state): State<AppState>,
4510    AxumPath(wait_id): AxumPath<String>,
4511) -> Response {
4512    // wait_id comes from our own UUID generator; reject any structurally malformed value.
4513    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4514        return error::bad_request("invalid wait_id");
4515    }
4516    let run_state = {
4517        let runs = state.async_runs.lock().await;
4518        runs.get(&wait_id).cloned()
4519    };
4520    match run_state {
4521        None => error::not_found("run not found"),
4522        Some(AsyncRunState::Running {
4523            started_at,
4524            phase,
4525            files_done,
4526            files_total,
4527            ..
4528        }) => {
4529            // Treat runs older than 2 h as timed out (analysis should finish well under that).
4530            if started_at.elapsed() > std::time::Duration::from_hours(2) {
4531                let mut runs = state.async_runs.lock().await;
4532                runs.insert(
4533                    wait_id,
4534                    AsyncRunState::Failed {
4535                        message: "Analysis timed out after 2 hours.".to_string(),
4536                    },
4537                );
4538                drop(runs);
4539                return Json(AsyncRunStatusResponse::Failed {
4540                    message: "Analysis timed out after 2 hours.".to_string(),
4541                })
4542                .into_response();
4543            }
4544            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4545            Json(AsyncRunStatusResponse::Running {
4546                elapsed_secs: started_at.elapsed().as_secs(),
4547                phase: phase_str,
4548                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4549                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4550            })
4551            .into_response()
4552        }
4553        Some(AsyncRunState::Complete { run_id }) => {
4554            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4555        }
4556        Some(AsyncRunState::Failed { message }) => {
4557            Json(AsyncRunStatusResponse::Failed { message }).into_response()
4558        }
4559        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4560    }
4561}
4562
4563async fn cancel_run_handler(
4564    State(state): State<AppState>,
4565    AxumPath(wait_id): AxumPath<String>,
4566) -> Response {
4567    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4568        return error::bad_request("invalid wait_id");
4569    }
4570    let mut runs = state.async_runs.lock().await;
4571    let resp = match runs.get(&wait_id) {
4572        Some(AsyncRunState::Running { cancel_token, .. }) => {
4573            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4574            runs.insert(wait_id, AsyncRunState::Cancelled);
4575            StatusCode::OK.into_response()
4576        }
4577        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4578        _ => error::not_found("run not found"),
4579    };
4580    drop(runs);
4581    resp
4582}
4583
4584async fn async_run_result_handler(
4585    State(state): State<AppState>,
4586    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4587    AxumPath(run_id): AxumPath<String>,
4588) -> Response {
4589    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4590        return StatusCode::BAD_REQUEST.into_response();
4591    }
4592
4593    let artifacts = {
4594        let map = state.artifacts.lock().await;
4595        map.get(&run_id).cloned()
4596    };
4597    let artifacts = if let Some(a) = artifacts {
4598        a
4599    } else {
4600        let reg = state.registry.lock().await;
4601        if let Some(entry) = reg.find_by_run_id(&run_id) {
4602            recover_artifacts_from_registry(entry)
4603        } else {
4604            let html = ErrorTemplate {
4605                message: format!(
4606                    "Report not found. Run ID {} is not in the scan history.",
4607                    &run_id[..run_id.len().min(8)]
4608                ),
4609                last_report_url: Some("/view-reports".to_string()),
4610                last_report_label: Some("View Reports".to_string()),
4611                run_id: Some(run_id.clone()),
4612                error_code: Some(404),
4613                csp_nonce: csp_nonce.clone(),
4614                version: env!("CARGO_PKG_VERSION"),
4615            }
4616            .render()
4617            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4618            return (StatusCode::NOT_FOUND, Html(html)).into_response();
4619        }
4620    };
4621
4622    let json_path = if let Some(p) = &artifacts.json_path {
4623        p.clone()
4624    } else {
4625        let html = ErrorTemplate {
4626            message: "JSON result was not saved for this run.".to_string(),
4627            last_report_url: Some("/view-reports".to_string()),
4628            last_report_label: Some("View Reports".to_string()),
4629            run_id: Some(run_id.clone()),
4630            error_code: Some(404),
4631            csp_nonce: csp_nonce.clone(),
4632            version: env!("CARGO_PKG_VERSION"),
4633        }
4634        .render()
4635        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4636        return (StatusCode::NOT_FOUND, Html(html)).into_response();
4637    };
4638
4639    let Ok(run) = read_json(&json_path) else {
4640        let folder_hint = json_path
4641            .parent()
4642            .map(|p| p.display().to_string())
4643            .unwrap_or_default();
4644        let redirect_url = format!("/runs/result/{run_id}");
4645        return missing_scan_relocate_response(
4646            &format!(
4647                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
4648                 deleted. Browse to the folder containing your scan output to reconnect it.",
4649                json_path.display()
4650            ),
4651            &run_id,
4652            &folder_hint,
4653            &redirect_url,
4654            state.server_mode,
4655            &csp_nonce,
4656        );
4657    };
4658
4659    let confluence_configured = {
4660        let store = state.confluence.lock().await;
4661        store.is_configured()
4662    };
4663
4664    render_result_page(
4665        &run,
4666        &artifacts,
4667        &run_id,
4668        &csp_nonce,
4669        confluence_configured,
4670        state.server_mode,
4671    )
4672}
4673
4674#[allow(clippy::too_many_lines)]
4675#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
4676fn render_result_page(
4677    run: &AnalysisRun,
4678    artifacts: &RunArtifacts,
4679    run_id: &str,
4680    csp_nonce: &str,
4681    confluence_configured: bool,
4682    server_mode: bool,
4683) -> Response {
4684    let ctx = &artifacts.result_context;
4685    let prev_entry = &ctx.prev_entry;
4686    let prev_scan_count = ctx.prev_scan_count;
4687    let project_path = &ctx.project_path;
4688
4689    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4690        prev.json_path
4691            .as_ref()
4692            .and_then(|p| read_json(p).ok())
4693            .map(|prev_run| compute_delta(&prev_run, run))
4694    });
4695
4696    let files_analyzed = run.per_file_records.len() as u64;
4697    let files_skipped = run.skipped_file_records.len() as u64;
4698    let physical_lines = run
4699        .totals_by_language
4700        .iter()
4701        .map(|r| r.total_physical_lines)
4702        .sum::<u64>();
4703    let code_lines = run
4704        .totals_by_language
4705        .iter()
4706        .map(|r| r.code_lines)
4707        .sum::<u64>();
4708    let comment_lines = run
4709        .totals_by_language
4710        .iter()
4711        .map(|r| r.comment_lines)
4712        .sum::<u64>();
4713    let blank_lines = run
4714        .totals_by_language
4715        .iter()
4716        .map(|r| r.blank_lines)
4717        .sum::<u64>();
4718    let mixed_lines = run
4719        .totals_by_language
4720        .iter()
4721        .map(|r| r.mixed_lines_separate)
4722        .sum::<u64>();
4723    let functions = run
4724        .totals_by_language
4725        .iter()
4726        .map(|r| r.functions)
4727        .sum::<u64>();
4728    let classes = run
4729        .totals_by_language
4730        .iter()
4731        .map(|r| r.classes)
4732        .sum::<u64>();
4733    let variables = run
4734        .totals_by_language
4735        .iter()
4736        .map(|r| r.variables)
4737        .sum::<u64>();
4738    let imports = run
4739        .totals_by_language
4740        .iter()
4741        .map(|r| r.imports)
4742        .sum::<u64>();
4743
4744    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4745    let prev_fa = prev_sum.map(|s| s.files_analyzed);
4746    let prev_fs = prev_sum.map(|s| s.files_skipped);
4747    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4748    let prev_cl = prev_sum.map(|s| s.code_lines);
4749    let prev_cml = prev_sum.map(|s| s.comment_lines);
4750    let prev_bl = prev_sum.map(|s| s.blank_lines);
4751    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4752    let prev_fa_str = fmt_prev(prev_fa);
4753    let prev_fs_str = fmt_prev(prev_fs);
4754    let prev_pl_str = fmt_prev(prev_pl);
4755    let prev_cl_str = fmt_prev(prev_cl);
4756    let prev_cml_str = fmt_prev(prev_cml);
4757    let prev_bl_str = fmt_prev(prev_bl);
4758    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4759    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4760    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4761    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4762    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4763    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4764    let delta_fa_class = delta_fa_class.to_string();
4765    let delta_fs_class = delta_fs_class.to_string();
4766    let delta_pl_class = delta_pl_class.to_string();
4767    let delta_cl_class = delta_cl_class.to_string();
4768    let delta_cml_class = delta_cml_class.to_string();
4769    let delta_bl_class = delta_bl_class.to_string();
4770
4771    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4772    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4773    let (delta_lines_net_str, delta_lines_net_class) =
4774        match (delta_lines_added, delta_lines_removed) {
4775            (Some(a), Some(r)) => {
4776                let net = a - r;
4777                (fmt_delta(net), delta_class(net).to_string())
4778            }
4779            _ => ("—".to_string(), "na".to_string()),
4780        };
4781
4782    let run_dir = artifacts.output_dir.clone();
4783    let git_branch = run.git_branch.clone();
4784    let git_commit = run.git_commit_short.clone();
4785    let git_commit_long = run.git_commit_long.clone();
4786    let git_author = run.git_commit_author.clone();
4787    let git_commit_url = run
4788        .git_remote_url
4789        .as_deref()
4790        .zip(run.git_commit_long.as_deref())
4791        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4792    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4793        format!(
4794            "{} / {}",
4795            run.environment.initiator_username, run.environment.initiator_hostname
4796        )
4797    });
4798    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
4799    let os_display = format!(
4800        "{} / {}",
4801        run.environment.operating_system, run.environment.architecture
4802    );
4803    let test_count = run.summary_totals.test_count;
4804
4805    let template = ResultTemplate {
4806        version: env!("CARGO_PKG_VERSION"),
4807        report_title: run.effective_configuration.reporting.report_title.clone(),
4808        project_path: project_path.clone(),
4809        output_dir: display_path(&artifacts.output_dir),
4810        run_id: run_id.to_owned(),
4811        run_id_short: run_id
4812            .split('-')
4813            .next_back()
4814            .unwrap_or(run_id)
4815            .chars()
4816            .take(7)
4817            .collect(),
4818        files_analyzed,
4819        files_skipped,
4820        physical_lines,
4821        code_lines,
4822        comment_lines,
4823        blank_lines,
4824        mixed_lines,
4825        functions,
4826        classes,
4827        variables,
4828        imports,
4829        html_url: artifacts
4830            .html_path
4831            .as_ref()
4832            .map(|_| format!("/runs/html/{run_id}")),
4833        pdf_url: artifacts
4834            .pdf_path
4835            .as_ref()
4836            .map(|_| format!("/runs/pdf/{run_id}")),
4837        json_url: artifacts
4838            .json_path
4839            .as_ref()
4840            .map(|_| format!("/runs/json/{run_id}")),
4841        html_download_url: artifacts
4842            .html_path
4843            .as_ref()
4844            .map(|_| format!("/runs/html/{run_id}?download=1")),
4845        pdf_download_url: artifacts
4846            .pdf_path
4847            .as_ref()
4848            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
4849        json_download_url: artifacts
4850            .json_path
4851            .as_ref()
4852            .map(|_| format!("/runs/json/{run_id}?download=1")),
4853        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
4854        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
4855        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
4856        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
4857        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
4858        prev_fa_str,
4859        prev_fs_str,
4860        prev_pl_str,
4861        prev_cl_str,
4862        prev_cml_str,
4863        prev_bl_str,
4864        delta_fa_str,
4865        delta_fa_class,
4866        delta_fs_str,
4867        delta_fs_class,
4868        delta_pl_str,
4869        delta_pl_class,
4870        delta_cl_str,
4871        delta_cl_class,
4872        delta_cml_str,
4873        delta_cml_class,
4874        delta_bl_str,
4875        delta_bl_class,
4876        delta_lines_added,
4877        delta_lines_removed,
4878        delta_lines_net_str,
4879        delta_lines_net_class,
4880        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
4881        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
4882        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
4883        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
4884        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
4885            d.file_deltas
4886                .iter()
4887                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
4888                .map(|f| {
4889                    #[allow(clippy::cast_sign_loss)]
4890                    let n = f.current_code as u64;
4891                    n
4892                })
4893                .sum()
4894        }),
4895        git_branch,
4896        git_commit,
4897        git_commit_long,
4898        git_author,
4899        git_commit_url,
4900        scan_performed_by,
4901        scan_time_display,
4902        os_display,
4903        test_count,
4904        current_scan_number: prev_scan_count + 1,
4905        prev_scan_count,
4906        submodule_rows: run
4907            .submodule_summaries
4908            .iter()
4909            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
4910            .collect(),
4911        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
4912        scan_config_url: format!("/runs/scan-config/{run_id}"),
4913        lang_chart_json: {
4914            let mut langs: Vec<&sloc_core::LanguageSummary> =
4915                run.totals_by_language.iter().collect();
4916            langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
4917            let entries: Vec<String> = langs
4918                .into_iter()
4919                .take(12)
4920                .map(|l| {
4921                    let name = l
4922                        .language
4923                        .display_name()
4924                        .replace('\\', "\\\\")
4925                        .replace('"', "\\\"");
4926                    format!(
4927                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
4928                        name,
4929                        l.code_lines,
4930                        l.comment_lines,
4931                        l.blank_lines,
4932                        l.total_physical_lines,
4933                        l.functions,
4934                        l.classes,
4935                        l.variables,
4936                        l.imports,
4937                        l.files,
4938                    )
4939                })
4940                .collect();
4941            format!("[{}]", entries.join(","))
4942        },
4943        scatter_chart_json: {
4944            let entries: Vec<String> = run
4945                .totals_by_language
4946                .iter()
4947                .map(|l| {
4948                    let name = l
4949                        .language
4950                        .display_name()
4951                        .replace('\\', "\\\\")
4952                        .replace('"', "\\\"");
4953                    format!(
4954                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
4955                        name, l.files, l.code_lines, l.total_physical_lines,
4956                    )
4957                })
4958                .collect();
4959            format!("[{}]", entries.join(","))
4960        },
4961        semantic_chart_json: {
4962            let entries: Vec<String> = run
4963                .totals_by_language
4964                .iter()
4965                .filter(|l| {
4966                    l.functions > 0
4967                        || l.classes > 0
4968                        || l.variables > 0
4969                        || l.imports > 0
4970                        || l.test_count > 0
4971                })
4972                .map(|l| {
4973                    let name = l
4974                        .language
4975                        .display_name()
4976                        .replace('\\', "\\\\")
4977                        .replace('"', "\\\"");
4978                    format!(
4979                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
4980                        name, l.functions, l.classes, l.variables, l.imports, l.test_count,
4981                    )
4982                })
4983                .collect();
4984            format!("[{}]", entries.join(","))
4985        },
4986        submodule_chart_json: {
4987            let entries: Vec<String> = run
4988                .submodule_summaries
4989                .iter()
4990                .map(|s| {
4991                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
4992                    format!(
4993                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
4994                        name,
4995                        s.code_lines,
4996                        s.comment_lines,
4997                        s.blank_lines,
4998                        s.total_physical_lines,
4999                        s.files_analyzed,
5000                    )
5001                })
5002                .collect();
5003            format!("[{}]", entries.join(","))
5004        },
5005        has_submodule_data: !run.submodule_summaries.is_empty(),
5006        has_semantic_data: run
5007            .totals_by_language
5008            .iter()
5009            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5010        csp_nonce: csp_nonce.to_owned(),
5011        confluence_configured,
5012        server_mode,
5013        report_header_footer: run
5014            .effective_configuration
5015            .reporting
5016            .report_header_footer
5017            .clone(),
5018        is_offline: false,
5019    };
5020
5021    Html(
5022        template
5023            .render()
5024            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5025    )
5026    .into_response()
5027}
5028
5029fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5030    let slug: String = report_title
5031        .chars()
5032        .map(|c| {
5033            if c.is_alphanumeric() || c == '-' {
5034                c.to_ascii_lowercase()
5035            } else {
5036                '_'
5037            }
5038        })
5039        .collect::<String>()
5040        .split('_')
5041        .filter(|s| !s.is_empty())
5042        .collect::<Vec<_>>()
5043        .join("_");
5044
5045    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5046
5047    if slug.is_empty() {
5048        format!("report_{short_id}.pdf")
5049    } else {
5050        format!("{slug}_{short_id}.pdf")
5051    }
5052}
5053
5054#[derive(Serialize)]
5055struct PdfStatusResponse {
5056    ready: bool,
5057}
5058
5059/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
5060/// Clients poll this to update the button state without page reloads.
5061async fn pdf_status_handler(
5062    State(state): State<AppState>,
5063    AxumPath(run_id): AxumPath<String>,
5064) -> Response {
5065    let pdf_path = {
5066        let registry = state.artifacts.lock().await;
5067        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5068    };
5069    let pdf_path = if pdf_path.is_some() {
5070        pdf_path
5071    } else {
5072        let reg = state.registry.lock().await;
5073        reg.find_by_run_id(&run_id)
5074            .map(recover_artifacts_from_registry)
5075            .and_then(|a| a.pdf_path)
5076    };
5077    let ready = pdf_path.is_some_and(|p| p.exists());
5078    Json(PdfStatusResponse { ready }).into_response()
5079}
5080
5081/// GET /`api/runs/:run_id/bundle`
5082///
5083/// Streams a gzip-compressed tar archive containing every artifact in the run's
5084/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
5085/// is built in memory so it never touches a temp file.
5086async fn download_bundle_handler(
5087    State(state): State<AppState>,
5088    AxumPath(run_id): AxumPath<String>,
5089) -> Response {
5090    // Resolve output directory from in-memory cache or persisted registry.
5091    let output_dir = {
5092        let cache = state.artifacts.lock().await;
5093        cache.get(&run_id).map(|a| a.output_dir.clone())
5094    };
5095    let output_dir = if let Some(d) = output_dir {
5096        d
5097    } else {
5098        let reg = state.registry.lock().await;
5099        match reg.find_by_run_id(&run_id) {
5100            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5101            None => {
5102                return (
5103                    StatusCode::NOT_FOUND,
5104                    Json(serde_json::json!({"error": "Run not found"})),
5105                )
5106                    .into_response();
5107            }
5108        }
5109    };
5110
5111    if !output_dir.exists() {
5112        return (
5113            StatusCode::NOT_FOUND,
5114            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5115        )
5116            .into_response();
5117    }
5118
5119    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
5120    let run_id_clone = run_id.clone();
5121    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5122        use flate2::{write::GzEncoder, Compression};
5123        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5124        {
5125            let mut tar = tar::Builder::new(&mut enc);
5126            tar.follow_symlinks(false);
5127            // Append every regular file in the output directory, skipping
5128            // sub-directories (the output dir is always flat).
5129            if let Ok(entries) = std::fs::read_dir(&output_dir) {
5130                for entry in entries.filter_map(Result::ok) {
5131                    let p = entry.path();
5132                    if p.is_file() {
5133                        let name = p.file_name().unwrap_or_default().to_string_lossy();
5134                        let archive_path = format!("{run_id_clone}/{name}");
5135                        tar.append_path_with_name(&p, &archive_path)?;
5136                    }
5137                }
5138            }
5139            tar.finish()?;
5140        }
5141        Ok(enc.finish()?)
5142    })
5143    .await;
5144
5145    match archive_result {
5146        Ok(Ok(bytes)) => {
5147            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5148            axum::response::Response::builder()
5149                .status(StatusCode::OK)
5150                .header("Content-Type", "application/gzip")
5151                .header(
5152                    "Content-Disposition",
5153                    format!("attachment; filename=\"{filename}\""),
5154                )
5155                .header("Content-Length", bytes.len().to_string())
5156                .body(axum::body::Body::from(bytes))
5157                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5158        }
5159        Ok(Err(e)) => (
5160            StatusCode::INTERNAL_SERVER_ERROR,
5161            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5162        )
5163            .into_response(),
5164        Err(e) => (
5165            StatusCode::INTERNAL_SERVER_ERROR,
5166            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5167        )
5168            .into_response(),
5169    }
5170}
5171
5172/// DELETE /`api/runs/:run_id`
5173///
5174/// Removes all on-disk artifacts for the run and purges the run from the
5175/// in-memory cache and the persisted registry. Returns 204 on success.
5176async fn delete_run_handler(
5177    State(state): State<AppState>,
5178    AxumPath(run_id): AxumPath<String>,
5179) -> Response {
5180    // Resolve output directory.
5181    let output_dir = {
5182        let mut cache = state.artifacts.lock().await;
5183        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5184        cache.remove(&run_id);
5185        dir
5186    };
5187    let output_dir = if let Some(d) = output_dir {
5188        d
5189    } else {
5190        let reg = state.registry.lock().await;
5191        reg.find_by_run_id(&run_id)
5192            .map(|e| recover_artifacts_from_registry(e).output_dir)
5193            .unwrap_or_default()
5194    };
5195
5196    // Remove from persisted registry.
5197    {
5198        let mut reg = state.registry.lock().await;
5199        reg.entries.retain(|e| e.run_id != run_id);
5200        let _ = reg.save(&state.registry_path);
5201    }
5202
5203    // Delete on-disk artifacts.
5204    if output_dir.exists() {
5205        if let Err(e) = tokio::fs::remove_dir_all(&output_dir).await {
5206            return (
5207                StatusCode::INTERNAL_SERVER_ERROR,
5208                Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5209            )
5210                .into_response();
5211        }
5212    }
5213
5214    StatusCode::NO_CONTENT.into_response()
5215}
5216
5217/// POST /api/runs/cleanup
5218///
5219/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
5220/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
5221async fn cleanup_runs_handler(
5222    State(state): State<AppState>,
5223    Json(body): Json<serde_json::Value>,
5224) -> Response {
5225    let days = body
5226        .get("older_than_days")
5227        .and_then(serde_json::Value::as_u64)
5228        .unwrap_or(30)
5229        .max(1);
5230
5231    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5232
5233    // Collect expired entries from the registry.
5234    let expired: Vec<(String, PathBuf)> = {
5235        let reg = state.registry.lock().await;
5236        reg.entries
5237            .iter()
5238            .filter(|e| e.timestamp_utc < cutoff)
5239            .map(|e| {
5240                let arts = recover_artifacts_from_registry(e);
5241                (e.run_id.clone(), arts.output_dir)
5242            })
5243            .collect()
5244    };
5245
5246    let mut deleted = 0usize;
5247    for (run_id, output_dir) in &expired {
5248        // Remove from in-memory cache.
5249        state.artifacts.lock().await.remove(run_id);
5250        // Delete on-disk artifacts (non-fatal if already gone).
5251        if output_dir.exists() {
5252            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5253                eprintln!(
5254                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5255                    output_dir.display()
5256                );
5257                continue;
5258            }
5259        }
5260        deleted += 1;
5261    }
5262
5263    // Purge expired run IDs from the registry in one pass.
5264    let expired_ids: std::collections::HashSet<&str> =
5265        expired.iter().map(|(id, _)| id.as_str()).collect();
5266    {
5267        let mut reg = state.registry.lock().await;
5268        reg.entries
5269            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5270        let _ = reg.save(&state.registry_path);
5271    }
5272
5273    Json(serde_json::json!({ "deleted": deleted })).into_response()
5274}
5275
5276/// Spawns the background auto-cleanup task. Returns a handle so the caller can
5277/// abort it when the policy is updated or disabled.
5278fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5279    tokio::spawn(async move {
5280        loop {
5281            let interval_secs = {
5282                let store = state.cleanup_policy.lock().await;
5283                match &store.policy {
5284                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5285                    _ => break,
5286                }
5287            };
5288            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5289            let n = run_auto_cleanup(&state).await;
5290            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5291        }
5292    })
5293}
5294
5295fn collect_runs_to_delete(
5296    reg: &ScanRegistry,
5297    max_age_days: Option<u32>,
5298    max_run_count: Option<u32>,
5299) -> std::collections::HashSet<String> {
5300    let mut to_delete = std::collections::HashSet::new();
5301    if let Some(days) = max_age_days {
5302        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5303        for e in &reg.entries {
5304            if e.timestamp_utc < cutoff {
5305                to_delete.insert(e.run_id.clone());
5306            }
5307        }
5308    }
5309    if let Some(max_count) = max_run_count {
5310        // entries are sorted newest-first; skip the ones we keep
5311        for e in reg.entries.iter().skip(max_count as usize) {
5312            to_delete.insert(e.run_id.clone());
5313        }
5314    }
5315    to_delete
5316}
5317
5318async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5319    let output_dir = {
5320        let mut cache = state.artifacts.lock().await;
5321        let d = cache.get(run_id).map(|a| a.output_dir.clone());
5322        cache.remove(run_id);
5323        d
5324    };
5325    let output_dir = if let Some(d) = output_dir {
5326        d
5327    } else {
5328        let reg = state.registry.lock().await;
5329        reg.find_by_run_id(run_id)
5330            .map(|e| recover_artifacts_from_registry(e).output_dir)
5331            .unwrap_or_default()
5332    };
5333    if output_dir.exists() {
5334        let _ = tokio::fs::remove_dir_all(&output_dir).await;
5335    }
5336}
5337
5338/// Core cleanup logic shared by the background task and the "Run Now" handler.
5339/// Applies both the age limit and the count limit, then updates `last_run_at`.
5340/// Returns the number of runs deleted.
5341async fn run_auto_cleanup(state: &AppState) -> u32 {
5342    let (max_age_days, max_run_count) = {
5343        let store = state.cleanup_policy.lock().await;
5344        match &store.policy {
5345            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5346            _ => return 0,
5347        }
5348    };
5349
5350    let to_delete = {
5351        let reg = state.registry.lock().await;
5352        collect_runs_to_delete(&reg, max_age_days, max_run_count)
5353    };
5354
5355    for run_id in &to_delete {
5356        delete_run_artifacts(state, run_id).await;
5357    }
5358
5359    // Purge from registry.
5360    if !to_delete.is_empty() {
5361        let mut reg = state.registry.lock().await;
5362        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5363        let _ = reg.save(&state.registry_path);
5364    }
5365
5366    let deleted = to_delete.len() as u32;
5367    {
5368        let mut store = state.cleanup_policy.lock().await;
5369        store.last_run_at = Some(chrono::Utc::now());
5370        store.last_run_deleted = Some(deleted);
5371        let _ = store.save(&state.cleanup_policy_path);
5372    }
5373    deleted
5374}
5375
5376// ── Auto-cleanup policy API ───────────────────────────────────────────────────
5377
5378/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
5379async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5380    let store = state.cleanup_policy.lock().await;
5381    Json(serde_json::json!({
5382        "policy": store.policy,
5383        "last_run_at": store.last_run_at,
5384        "last_run_deleted": store.last_run_deleted,
5385    }))
5386    .into_response()
5387}
5388
5389/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
5390async fn api_save_cleanup_policy(
5391    State(state): State<AppState>,
5392    Json(body): Json<CleanupPolicy>,
5393) -> Response {
5394    // Abort any running task so the new interval takes effect immediately.
5395    {
5396        let mut handle = state.cleanup_task_handle.lock().await;
5397        if let Some(h) = handle.take() {
5398            h.abort();
5399        }
5400    }
5401    {
5402        let mut store = state.cleanup_policy.lock().await;
5403        store.policy = Some(body.clone());
5404        if let Err(e) = store.save(&state.cleanup_policy_path) {
5405            return (
5406                StatusCode::INTERNAL_SERVER_ERROR,
5407                Json(serde_json::json!({"error": e.to_string()})),
5408            )
5409                .into_response();
5410        }
5411    }
5412    if body.enabled {
5413        let handle = spawn_cleanup_policy_task(state.clone());
5414        *state.cleanup_task_handle.lock().await = Some(handle);
5415    }
5416    StatusCode::NO_CONTENT.into_response()
5417}
5418
5419/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
5420async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5421    let deleted = run_auto_cleanup(&state).await;
5422    Json(serde_json::json!({ "deleted": deleted })).into_response()
5423}
5424
5425/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
5426async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5427    {
5428        let mut handle = state.cleanup_task_handle.lock().await;
5429        if let Some(h) = handle.take() {
5430            h.abort();
5431        }
5432    }
5433    {
5434        let mut store = state.cleanup_policy.lock().await;
5435        store.policy = None;
5436        let _ = store.save(&state.cleanup_policy_path);
5437    }
5438    StatusCode::NO_CONTENT.into_response()
5439}
5440
5441/// Serve the HTML artifact for a run — view or download.
5442/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
5443/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
5444/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
5445/// Only called for browser views; downloads keep the self-contained inline version.
5446fn swap_inline_chart_js_for_static(html: String) -> String {
5447    let Some(head_end) = html.find("</head>") else {
5448        return html;
5449    };
5450    let Some(script_start) = html[..head_end].rfind("<script") else {
5451        return html;
5452    };
5453    let Some(close_offset) = html[script_start..].find("</script>") else {
5454        return html;
5455    };
5456    let block_end = script_start + close_offset + "</script>".len();
5457    format!(
5458        "{}<script src=\"/static/chart-report.js\"></script>{}",
5459        &html[..script_start],
5460        &html[block_end..]
5461    )
5462}
5463
5464/// current-request Content-Security-Policy nonce check.
5465fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5466    // Find the first nonce value that was baked in at render time.
5467    let Some(start) = html.find("nonce=\"") else {
5468        // Reports generated before nonce support was added have bare <style> and <script>
5469        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
5470        // the inline blocks — without it the browser blocks all CSS and JS.
5471        return html
5472            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5473            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5474    };
5475    let value_start = start + 7; // len(r#"nonce=""#) == 7
5476    let Some(end_offset) = html[value_start..].find('"') else {
5477        return html.to_owned();
5478    };
5479    let old_nonce = &html[value_start..value_start + end_offset];
5480    html.replace(
5481        &format!("nonce=\"{old_nonce}\""),
5482        &format!("nonce=\"{new_nonce}\""),
5483    )
5484}
5485
5486fn serve_html_artifact(
5487    path: &Path,
5488    wants_download: bool,
5489    csp_nonce: &str,
5490    run_id: &str,
5491    server_mode: bool,
5492) -> Response {
5493    match fs::read_to_string(path) {
5494        Ok(raw) => {
5495            // Patch the saved nonce so inline styles/scripts pass CSP.
5496            let content = patch_html_nonce(&raw, csp_nonce);
5497            if wants_download {
5498                // Keep the self-contained inline version for downloads (opened as file://).
5499                (
5500                    [
5501                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5502                        (
5503                            header::CONTENT_DISPOSITION,
5504                            "attachment; filename=report.html",
5505                        ),
5506                    ],
5507                    content,
5508                )
5509                    .into_response()
5510            } else {
5511                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
5512                // browser caches it after the first view; the HTML response also shrinks.
5513                Html(swap_inline_chart_js_for_static(content)).into_response()
5514            }
5515        }
5516        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5517            let filename = path.file_name().map_or_else(
5518                || "report.html".to_string(),
5519                |n| n.to_string_lossy().into_owned(),
5520            );
5521            let html = LocateFileTemplate {
5522                run_id: run_id.to_owned(),
5523                artifact_type: "html".to_string(),
5524                expected_filename: filename,
5525                server_mode,
5526                csp_nonce: csp_nonce.to_owned(),
5527                version: env!("CARGO_PKG_VERSION"),
5528            }
5529            .render()
5530            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5531            (StatusCode::NOT_FOUND, Html(html)).into_response()
5532        }
5533        Err(err) => {
5534            let filename = path.file_name().map_or_else(
5535                || "report.html".to_string(),
5536                |n| n.to_string_lossy().into_owned(),
5537            );
5538            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5539            let html = ErrorTemplate {
5540                message: msg,
5541                last_report_url: Some("/view-reports".to_string()),
5542                last_report_label: Some("View Reports".to_string()),
5543                run_id: None,
5544                error_code: Some(404),
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    }
5553}
5554
5555/// Serve the PDF artifact for a run — inline or download.
5556fn serve_pdf_artifact(
5557    path: &Path,
5558    report_title: &str,
5559    run_id: &str,
5560    wants_download: bool,
5561    csp_nonce: &str,
5562) -> Response {
5563    match fs::read(path) {
5564        Ok(bytes) => {
5565            let filename = build_pdf_filename(report_title, run_id);
5566            let disposition = if wants_download {
5567                format!("attachment; filename=\"{filename}\"")
5568            } else {
5569                format!("inline; filename=\"{filename}\"")
5570            };
5571            (
5572                [
5573                    (header::CONTENT_TYPE, "application/pdf".to_string()),
5574                    (header::CONTENT_DISPOSITION, disposition),
5575                ],
5576                bytes,
5577            )
5578                .into_response()
5579        }
5580        Err(err) => {
5581            let filename = path.file_name().map_or_else(
5582                || "report.pdf".to_string(),
5583                |n| n.to_string_lossy().into_owned(),
5584            );
5585            let msg = format!(
5586                "PDF report '{filename}' could not be read.\n\n\
5587                 Error: {err}\n\n\
5588                 If you moved or renamed the output folder, the stored path is now stale. \
5589                 Use 'Open PDF folder' from the results page to browse the output directory."
5590            );
5591            let html = ErrorTemplate {
5592                message: msg,
5593                last_report_url: Some("/view-reports".to_string()),
5594                last_report_label: Some("View Reports".to_string()),
5595                run_id: Some(run_id.to_owned()),
5596                error_code: Some(404),
5597                csp_nonce: csp_nonce.to_owned(),
5598                version: env!("CARGO_PKG_VERSION"),
5599            }
5600            .render()
5601            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5602            (StatusCode::NOT_FOUND, Html(html)).into_response()
5603        }
5604    }
5605}
5606
5607/// Serve the JSON artifact for a run — view or download.
5608fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5609    match fs::read(path) {
5610        Ok(bytes) => {
5611            if wants_download {
5612                (
5613                    [
5614                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5615                        (
5616                            header::CONTENT_DISPOSITION,
5617                            "attachment; filename=result.json",
5618                        ),
5619                    ],
5620                    bytes,
5621                )
5622                    .into_response()
5623            } else {
5624                (
5625                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5626                    bytes,
5627                )
5628                    .into_response()
5629            }
5630        }
5631        Err(err) => {
5632            let filename = path.file_name().map_or_else(
5633                || "result.json".to_string(),
5634                |n| n.to_string_lossy().into_owned(),
5635            );
5636            let msg = format!(
5637                "JSON result '{filename}' could not be read.\n\n\
5638                 Error: {err}\n\n\
5639                 If you moved or renamed the output folder, the stored path is now stale. \
5640                 Use 'Open JSON folder' from the results page to browse the output directory."
5641            );
5642            let html = ErrorTemplate {
5643                message: msg,
5644                last_report_url: Some("/view-reports".to_string()),
5645                last_report_label: Some("View Reports".to_string()),
5646                run_id: None,
5647                error_code: Some(404),
5648                csp_nonce: csp_nonce.to_owned(),
5649                version: env!("CARGO_PKG_VERSION"),
5650            }
5651            .render()
5652            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5653            (StatusCode::NOT_FOUND, Html(html)).into_response()
5654        }
5655    }
5656}
5657
5658/// Recover a `RunArtifacts` from the persisted registry for a run ID.
5659fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5660    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
5661    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
5662    let output_dir = entry
5663        .html_path
5664        .as_ref()
5665        .or(entry.json_path.as_ref())
5666        .or(entry.pdf_path.as_ref())
5667        .or(entry.csv_path.as_ref())
5668        .or(entry.xlsx_path.as_ref())
5669        .and_then(|p| {
5670            let parent = p.parent()?;
5671            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5672            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
5673            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5674                parent.parent().map(PathBuf::from)
5675            } else {
5676                Some(parent.to_path_buf())
5677            }
5678        })
5679        .unwrap_or_default();
5680    // Recover pdf_path: use the persisted one, or look for report.pdf
5681    // adjacent to html/json if only the old entries lack it.
5682    let pdf_path = entry.pdf_path.clone().or_else(|| {
5683        let candidate = output_dir.join("report.pdf");
5684        candidate.exists().then_some(candidate)
5685    });
5686    // csv_path / xlsx_path: persisted paths take precedence; fall back to
5687    // scanning the run directory for files matching the expected patterns so
5688    // that runs created before this feature still surface their artifacts.
5689    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5690        // Check excel/ subfolder (new layout) then root (old layout).
5691        for dir in &[output_dir.join("excel"), output_dir.clone()] {
5692            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5693                entries
5694                    .filter_map(std::result::Result::ok)
5695                    .find(|e| {
5696                        let n = e.file_name();
5697                        let n = n.to_string_lossy();
5698                        n.starts_with("report_") && n.ends_with(ext)
5699                    })
5700                    .map(|e| e.path())
5701            }) {
5702                return Some(p);
5703            }
5704        }
5705        None
5706    };
5707
5708    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5709    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5710    RunArtifacts {
5711        output_dir: output_dir.clone(),
5712        html_path: entry.html_path.clone(),
5713        pdf_path,
5714        json_path: entry.json_path.clone(),
5715        csv_path,
5716        xlsx_path,
5717        scan_config_path: find_scan_config_in_dir(&output_dir),
5718        report_title: entry.project_label.clone(),
5719        result_context: RunResultContext::default(),
5720    }
5721}
5722
5723#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5724async fn resolve_artifact_set(
5725    state: &AppState,
5726    run_id: &str,
5727    csp_nonce: &str,
5728) -> Result<RunArtifacts, Response> {
5729    let cached = state.artifacts.lock().await.get(run_id).cloned();
5730    if let Some(a) = cached {
5731        return Ok(a);
5732    }
5733    let reg = state.registry.lock().await;
5734    if let Some(entry) = reg.find_by_run_id(run_id) {
5735        return Ok(recover_artifacts_from_registry(entry));
5736    }
5737    drop(reg);
5738    let short_id = &run_id[..run_id.len().min(8)];
5739    let hint = if matches!(
5740        run_id,
5741        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
5742    ) {
5743        format!(
5744            " The URL format appears to be reversed — \
5745             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
5746             Use the View Reports page to navigate to your scan."
5747        )
5748    } else {
5749        " The report may have been deleted or the report directory moved. \
5750         Use View Reports to browse your scan history."
5751            .to_string()
5752    };
5753    let error_html = ErrorTemplate {
5754        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
5755        last_report_url: Some("/view-reports".to_string()),
5756        last_report_label: Some("View Reports".to_string()),
5757        run_id: None,
5758        error_code: Some(404),
5759        csp_nonce: csp_nonce.to_owned(),
5760        version: env!("CARGO_PKG_VERSION"),
5761    }
5762    .render()
5763    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5764    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
5765}
5766
5767/// Return the path to a run's PDF, queuing background generation when it is missing.
5768///
5769/// Returns `Ok(path)` when the PDF is known (it may still be generating).
5770/// Returns `Err(response)` when there is no JSON source to regenerate from.
5771async fn resolve_or_queue_pdf(
5772    state: &AppState,
5773    pdf_path: Option<PathBuf>,
5774    json_path: Option<PathBuf>,
5775    output_dir: PathBuf,
5776    run_id: &str,
5777    report_title: &str,
5778    csp_nonce: &str,
5779) -> Result<PathBuf, Response> {
5780    if let Some(p) = pdf_path {
5781        return Ok(p);
5782    }
5783    let Some(json_src) = json_path.filter(|p| p.exists()) else {
5784        let msg = "PDF report was not generated for this run. \
5785                   Re-run the analysis with PDF output enabled."
5786            .to_string();
5787        let html = ErrorTemplate {
5788            message: msg,
5789            last_report_url: Some(format!("/runs/html/{run_id}")),
5790            last_report_label: Some("View HTML Report".to_string()),
5791            run_id: Some(run_id.to_string()),
5792            error_code: Some(404),
5793            csp_nonce: csp_nonce.to_string(),
5794            version: env!("CARGO_PKG_VERSION"),
5795        }
5796        .render()
5797        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
5798        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
5799    };
5800    let pdf_filename = build_pdf_filename(report_title, run_id);
5801    let pdf_dest = output_dir.join(&pdf_filename);
5802    if !pdf_dest.exists() {
5803        // Record the pending path so concurrent requests show the spinner.
5804        {
5805            let mut map = state.artifacts.lock().await;
5806            if let Some(entry) = map.get_mut(run_id) {
5807                entry.pdf_path = Some(pdf_dest.clone());
5808            }
5809        }
5810        {
5811            let mut reg = state.registry.lock().await;
5812            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
5813                e.pdf_path = Some(pdf_dest.clone());
5814            }
5815            let _ = reg.save(&state.registry_path);
5816        }
5817        spawn_native_pdf_background(
5818            json_src,
5819            pdf_dest.clone(),
5820            run_id.to_string(),
5821            state.artifacts.clone(),
5822        );
5823    }
5824    Ok(pdf_dest)
5825}
5826
5827/// Self-refreshing "please wait" page shown while the background PDF task is still running.
5828fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
5829    let html = format!(
5830                    "<!doctype html><html lang=\"en\"><head>\
5831                     <meta charset=utf-8>\
5832                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
5833                     <meta http-equiv=\"refresh\" content=\"5\">\
5834                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
5835                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
5836                     <style nonce=\"{csp_nonce}\">\
5837                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
5838                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
5839                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
5840                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
5841                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
5842                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
5843                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
5844                     background:var(--bg);color:var(--text);}}\
5845                     .top-nav{{position:sticky;top:0;z-index:30;\
5846                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
5847                     border-bottom:1px solid rgba(255,255,255,0.12);\
5848                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
5849                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
5850                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
5851                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
5852                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
5853                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
5854                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
5855                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
5856                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
5857                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
5858                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
5859                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
5860                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
5861                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
5862                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
5863                     justify-content:center;min-height:38px;border-radius:999px;\
5864                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
5865                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
5866                     .theme-toggle .icon-sun{{display:none;}}\
5867                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
5868                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
5869                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
5870                     display:flex;align-items:center;justify-content:center;\
5871                     min-height:calc(100vh - 56px);}}\
5872                     .panel{{background:var(--surface);border:1px solid var(--line);\
5873                     border-radius:var(--radius);box-shadow:var(--shadow);\
5874                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
5875                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
5876                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
5877                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
5878                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
5879                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
5880                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
5881                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
5882                     min-height:42px;padding:0 20px;border-radius:14px;\
5883                     border:1px solid var(--line-strong);text-decoration:none;\
5884                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
5885                     .back-link:hover{{background:var(--line);}}\
5886                     </style></head>\
5887                     <body>\
5888                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
5889                       <a class=\"brand\" href=\"/\">\
5890                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
5891                         <div class=\"brand-copy\">\
5892                           <div class=\"brand-title\">OxideSLOC</div>\
5893                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
5894                         </div>\
5895                       </a>\
5896                       <div class=\"nav-right\">\
5897                         <a class=\"nav-pill\" href=\"/\">Home</a>\
5898                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
5899                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
5900                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
5901                           <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>\
5902                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
5903                           <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>\
5904                         </button>\
5905                       </div>\
5906                     </div></div>\
5907                     <div class=\"page\"><div class=\"panel\">\
5908                       <div class=\"spin-ring\"></div>\
5909                       <h1>Generating PDF\u{2026}</h1>\
5910                       <p>The PDF is being generated from the scan results.<br>\
5911                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
5912                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
5913                     </div></div>\
5914                     <script nonce=\"{csp_nonce}\">\
5915                     (function(){{\
5916                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
5917                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
5918                       var t=document.getElementById(\"theme-toggle\");\
5919                       if(t)t.addEventListener(\"click\",function(){{\
5920                         var d=b.classList.toggle(\"dark-theme\");\
5921                         localStorage.setItem(k,d?\"dark\":\"light\");\
5922                       }});\
5923                     }})();\
5924                     </script>\
5925                     </body></html>"
5926    );
5927    Html(html).into_response()
5928}
5929
5930/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
5931fn render_error_artifact_html(
5932    message: String,
5933    last_report_url: Option<String>,
5934    last_report_label: Option<String>,
5935    run_id: Option<String>,
5936    error_code: Option<u16>,
5937    csp_nonce: &str,
5938) -> String {
5939    ErrorTemplate {
5940        message,
5941        last_report_url,
5942        last_report_label,
5943        run_id,
5944        error_code,
5945        csp_nonce: csp_nonce.to_owned(),
5946        version: env!("CARGO_PKG_VERSION"),
5947    }
5948    .render()
5949    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
5950}
5951
5952/// Read a file and serve it as an attachment download.
5953fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
5954    fs::read(path).map_or_else(
5955        |_| StatusCode::NOT_FOUND.into_response(),
5956        |bytes| {
5957            let filename = path.file_name().map_or_else(
5958                || fallback_filename.to_string(),
5959                |n| n.to_string_lossy().into_owned(),
5960            );
5961            (
5962                [
5963                    (header::CONTENT_TYPE, content_type.to_string()),
5964                    (
5965                        header::CONTENT_DISPOSITION,
5966                        format!("attachment; filename=\"{filename}\""),
5967                    ),
5968                ],
5969                bytes,
5970            )
5971                .into_response()
5972        },
5973    )
5974}
5975
5976fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5977    let Some(path) = csv_path else {
5978        let html = render_error_artifact_html(
5979            "CSV report was not generated for this run, or was not recorded in \
5980             the scan registry."
5981                .to_string(),
5982            Some(format!("/runs/html/{run_id}")),
5983            Some("View HTML Report".to_string()),
5984            Some(run_id.to_string()),
5985            Some(404),
5986            csp_nonce,
5987        );
5988        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5989    };
5990    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
5991}
5992
5993fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
5994    let Some(path) = xlsx_path else {
5995        let html = render_error_artifact_html(
5996            "Excel report was not generated for this run, or was not recorded in \
5997             the scan registry."
5998                .to_string(),
5999            Some(format!("/runs/html/{run_id}")),
6000            Some("View HTML Report".to_string()),
6001            Some(run_id.to_string()),
6002            Some(404),
6003            csp_nonce,
6004        );
6005        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6006    };
6007    serve_binary_download(
6008        &path,
6009        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6010        "report.xlsx",
6011    )
6012}
6013
6014fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6015    let path = artifact_set
6016        .scan_config_path
6017        .as_deref()
6018        .map(std::path::Path::to_path_buf)
6019        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6020        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6021    fs::read(&path).map_or_else(
6022        |_| StatusCode::NOT_FOUND.into_response(),
6023        |bytes| {
6024            (
6025                [
6026                    (
6027                        header::CONTENT_TYPE,
6028                        "application/json; charset=utf-8".to_string(),
6029                    ),
6030                    (
6031                        header::CONTENT_DISPOSITION,
6032                        "attachment; filename=\"scan-config.json\"".to_string(),
6033                    ),
6034                ],
6035                bytes,
6036            )
6037                .into_response()
6038        },
6039    )
6040}
6041
6042fn serve_submodule_arm(
6043    artifact: &str,
6044    artifact_set: RunArtifacts,
6045    wants_download: bool,
6046    csp_nonce: &str,
6047    run_id: &str,
6048    server_mode: bool,
6049) -> Response {
6050    if artifact.len() > 128
6051        || !artifact
6052            .chars()
6053            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6054    {
6055        return StatusCode::BAD_REQUEST.into_response();
6056    }
6057    let filename = format!("{artifact}.html");
6058    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
6059    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6060    let path = if new_layout.exists() {
6061        new_layout
6062    } else {
6063        artifact_set.output_dir.join(&filename)
6064    };
6065    if !path.exists() {
6066        let html = render_error_artifact_html(
6067            format!(
6068                "Sub-report '{artifact}' was not found in the run directory.\n\
6069                 Re-run the analysis with 'Detect and separate git submodules' \
6070                 and HTML output enabled."
6071            ),
6072            Some("/view-reports".to_string()),
6073            Some("View Reports".to_string()),
6074            Some(run_id.to_string()),
6075            Some(404),
6076            csp_nonce,
6077        );
6078        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6079    }
6080    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6081}
6082
6083async fn serve_pdf_arm(
6084    state: &AppState,
6085    artifact_set: RunArtifacts,
6086    wants_download: bool,
6087    run_id: &str,
6088    csp_nonce: &str,
6089) -> Response {
6090    let report_title = artifact_set.report_title.clone();
6091    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6092    let stale_html_name = artifact_set
6093        .html_path
6094        .as_deref()
6095        .and_then(|p| p.file_name())
6096        .map(|n| n.to_string_lossy().into_owned());
6097    let path = match resolve_or_queue_pdf(
6098        state,
6099        artifact_set.pdf_path,
6100        artifact_set.json_path.clone(),
6101        artifact_set.output_dir.clone(),
6102        run_id,
6103        &report_title,
6104        csp_nonce,
6105    )
6106    .await
6107    {
6108        Ok(p) => p,
6109        Err(r) => return r,
6110    };
6111    if !path.exists() {
6112        // Distinguish a stale registry path (folder moved) from an in-progress
6113        // background generation. Only show the locate page when the PDF was
6114        // already recorded in the registry but the file is now missing.
6115        if had_pdf_in_registry {
6116            if let Some(expected_filename) = stale_html_name {
6117                let html = LocateFileTemplate {
6118                    run_id: run_id.to_string(),
6119                    artifact_type: "pdf".to_string(),
6120                    expected_filename,
6121                    server_mode: state.server_mode,
6122                    csp_nonce: csp_nonce.to_string(),
6123                    version: env!("CARGO_PKG_VERSION"),
6124                }
6125                .render()
6126                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6127                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6128            }
6129        }
6130        return pdf_generating_response(run_id, csp_nonce);
6131    }
6132    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6133}
6134
6135async fn artifact_handler(
6136    State(state): State<AppState>,
6137    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6138    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6139    Query(query): Query<ArtifactQuery>,
6140) -> Response {
6141    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6142        Ok(a) => a,
6143        Err(r) => return r,
6144    };
6145
6146    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6147
6148    match artifact.as_str() {
6149        "html" => {
6150            let Some(path) = artifact_set.html_path else {
6151                return StatusCode::NOT_FOUND.into_response();
6152            };
6153            serve_html_artifact(
6154                &path,
6155                wants_download,
6156                &csp_nonce,
6157                &run_id,
6158                state.server_mode,
6159            )
6160        }
6161        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6162        "json" => {
6163            let Some(path) = artifact_set.json_path else {
6164                let html = render_error_artifact_html(
6165                    "JSON result was not generated for this run, or was not recorded in \
6166                     the scan registry. Re-run the analysis with JSON output enabled."
6167                        .to_string(),
6168                    Some("/view-reports".to_string()),
6169                    Some("View Reports".to_string()),
6170                    Some(run_id.clone()),
6171                    Some(404),
6172                    &csp_nonce,
6173                );
6174                return (StatusCode::NOT_FOUND, Html(html)).into_response();
6175            };
6176            serve_json_artifact(&path, wants_download, &csp_nonce)
6177        }
6178        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6179        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6180        "scan-config" => serve_scan_config_arm(&artifact_set),
6181        _ if artifact.starts_with("sub_") => serve_submodule_arm(
6182            &artifact,
6183            artifact_set,
6184            wants_download,
6185            &csp_nonce,
6186            &run_id,
6187            state.server_mode,
6188        ),
6189        _ => StatusCode::NOT_FOUND.into_response(),
6190    }
6191}
6192
6193// ── History ───────────────────────────────────────────────────────────────────
6194
6195struct SubmoduleLinkRow {
6196    name: String,
6197    url: String,
6198}
6199
6200struct HistoryEntryRow {
6201    run_id: String,
6202    run_id_short: String,
6203    timestamp: String,
6204    timestamp_utc_ms: i64,
6205    project_label: String,
6206    project_path: String,
6207    files_analyzed: u64,
6208    files_skipped: u64,
6209    code_lines: u64,
6210    comment_lines: u64,
6211    blank_lines: u64,
6212    git_branch: String,
6213    git_commit: String,
6214    has_html: bool,
6215    has_json: bool,
6216    has_pdf: bool,
6217    submodule_links: Vec<SubmoduleLinkRow>,
6218    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
6219    submodule_names_csv: String,
6220}
6221
6222/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
6223fn nth_weekday_of_month(
6224    year: i32,
6225    month: u32,
6226    weekday: chrono::Weekday,
6227    n: u32,
6228) -> chrono::NaiveDate {
6229    use chrono::Datelike;
6230    let mut count = 0u32;
6231    let mut day = 1u32;
6232    loop {
6233        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6234        if d.weekday() == weekday {
6235            count += 1;
6236            if count == n {
6237                return d;
6238            }
6239        }
6240        day += 1;
6241    }
6242}
6243
6244/// Returns true if `dt` falls within US Pacific Daylight Time.
6245/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
6246/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
6247fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6248    use chrono::{Datelike, TimeZone};
6249    let year = dt.year();
6250    let dst_start = chrono::Utc.from_utc_datetime(
6251        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6252            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6253    );
6254    let dst_end = chrono::Utc.from_utc_datetime(
6255        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6256            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6257    );
6258    dt >= dst_start && dt < dst_end
6259}
6260
6261fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6262    if is_pacific_dst(dt) {
6263        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6264            .format("%Y-%m-%d %H:%M PDT")
6265            .to_string()
6266    } else {
6267        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6268            .format("%Y-%m-%d %H:%M PST")
6269            .to_string()
6270    }
6271}
6272
6273/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
6274fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6275    let (offset, tz) = if is_pacific_dst(dt) {
6276        (
6277            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6278            "PDT",
6279        )
6280    } else {
6281        (
6282            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6283            "PST",
6284        )
6285    };
6286    format!(
6287        "{} {tz}",
6288        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6289    )
6290}
6291
6292fn fmt_git_date(iso: &str) -> Option<String> {
6293    chrono::DateTime::parse_from_rfc3339(iso)
6294        .ok()
6295        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6296}
6297
6298fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6299    reg.entries
6300        .iter()
6301        .map(|e| {
6302            let submodule_links = {
6303                let mut links: Vec<SubmoduleLinkRow> = vec![];
6304                let sub_dir = e
6305                    .html_path
6306                    .as_ref()
6307                    .and_then(|p| p.parent())
6308                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6309                if let Some(dir) = sub_dir {
6310                    if let Ok(rd) = std::fs::read_dir(dir) {
6311                        for entry_res in rd.flatten() {
6312                            let fname = entry_res.file_name();
6313                            let fname_str = fname.to_string_lossy();
6314                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6315                                let stem = &fname_str[..fname_str.len() - 5];
6316                                let display = stem[4..].replace('-', " ");
6317                                links.push(SubmoduleLinkRow {
6318                                    name: display,
6319                                    url: format!("/runs/{stem}/{}", e.run_id),
6320                                });
6321                            }
6322                        }
6323                    }
6324                }
6325                links.sort_by(|a, b| a.name.cmp(&b.name));
6326                links
6327            };
6328            let submodule_names_csv = submodule_links
6329                .iter()
6330                .map(|l| l.name.as_str())
6331                .collect::<Vec<_>>()
6332                .join(",");
6333            HistoryEntryRow {
6334                run_id: e.run_id.clone(),
6335                run_id_short: e
6336                    .run_id
6337                    .split('-')
6338                    .next_back()
6339                    .unwrap_or(&e.run_id)
6340                    .chars()
6341                    .take(7)
6342                    .collect(),
6343                timestamp: fmt_la_time(e.timestamp_utc),
6344                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6345                project_label: e.project_label.clone(),
6346                project_path: e
6347                    .input_roots
6348                    .first()
6349                    .map(|s| sanitize_path_str(s))
6350                    .unwrap_or_default(),
6351                files_analyzed: e.summary.files_analyzed,
6352                files_skipped: e.summary.files_skipped,
6353                code_lines: e.summary.code_lines,
6354                comment_lines: e.summary.comment_lines,
6355                blank_lines: e.summary.blank_lines,
6356                git_branch: e.git_branch.clone().unwrap_or_default(),
6357                git_commit: e.git_commit.clone().unwrap_or_default(),
6358                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6359                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6360                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6361                submodule_links,
6362                submodule_names_csv,
6363            }
6364        })
6365        .collect()
6366}
6367
6368#[derive(Deserialize, Default)]
6369struct HistoryQuery {
6370    linked: Option<String>,
6371    error: Option<String>,
6372}
6373
6374async fn history_handler(
6375    State(state): State<AppState>,
6376    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6377    Query(query): Query<HistoryQuery>,
6378) -> impl IntoResponse {
6379    // Auto-scan all watched directories before rendering so the list stays fresh.
6380    auto_scan_watched_dirs(&state).await;
6381    let watched_dirs: Vec<String> = {
6382        let wd = state.watched_dirs.lock().await;
6383        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6384    };
6385    let mut entries = {
6386        let reg = state.registry.lock().await;
6387        make_history_rows(&reg)
6388    };
6389    entries.retain(|e| e.has_html);
6390    let total_scans = entries.len();
6391    let linked_count = query
6392        .linked
6393        .as_deref()
6394        .and_then(|s| s.parse::<usize>().ok())
6395        .unwrap_or(0);
6396    let browse_error = query.error.filter(|s| !s.is_empty());
6397    let template = HistoryTemplate {
6398        version: env!("CARGO_PKG_VERSION"),
6399        entries,
6400        total_scans,
6401        linked_count,
6402        browse_error,
6403        watched_dirs,
6404        csp_nonce,
6405        server_mode: state.server_mode,
6406    };
6407    Html(
6408        template
6409            .render()
6410            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6411    )
6412    .into_response()
6413}
6414
6415async fn compare_select_handler(
6416    State(state): State<AppState>,
6417    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6418) -> impl IntoResponse {
6419    auto_scan_watched_dirs(&state).await;
6420    let watched_dirs: Vec<String> = {
6421        let wd = state.watched_dirs.lock().await;
6422        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6423    };
6424    let mut entries = {
6425        let reg = state.registry.lock().await;
6426        make_history_rows(&reg)
6427    };
6428    entries.retain(|e| e.has_json);
6429    let total_scans = entries.len();
6430    let template = CompareSelectTemplate {
6431        version: env!("CARGO_PKG_VERSION"),
6432        entries,
6433        total_scans,
6434        watched_dirs,
6435        csp_nonce,
6436        server_mode: state.server_mode,
6437    };
6438    Html(
6439        template
6440            .render()
6441            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6442    )
6443    .into_response()
6444}
6445
6446// ── Compare ───────────────────────────────────────────────────────────────────
6447
6448#[derive(Deserialize, Default)]
6449struct CompareQuery {
6450    a: Option<String>,
6451    b: Option<String>,
6452    /// Optional submodule name to scope the comparison to one submodule.
6453    sub: Option<String>,
6454    /// "super" to exclude all submodule files and show only the super-repo.
6455    scope: Option<String>,
6456}
6457
6458struct CompareFileDeltaRow {
6459    relative_path: String,
6460    language: String,
6461    status: String,
6462    baseline_code: i64,
6463    current_code: i64,
6464    code_delta_str: String,
6465    code_delta_class: String,
6466    comment_delta_str: String,
6467    comment_delta_class: String,
6468    total_delta_str: String,
6469    total_delta_class: String,
6470}
6471
6472/// Recompute `summary_totals` from the current `per_file_records` slice.
6473/// Used when `per_file_records` has been narrowed to a submodule subset.
6474fn recompute_summary_from_records(run: &mut AnalysisRun) {
6475    let files_analyzed = run
6476        .per_file_records
6477        .iter()
6478        .filter(|r| r.language.is_some())
6479        .count() as u64;
6480    let code_lines: u64 = run
6481        .per_file_records
6482        .iter()
6483        .map(|r| r.effective_counts.code_lines)
6484        .sum();
6485    let comment_lines: u64 = run
6486        .per_file_records
6487        .iter()
6488        .map(|r| r.effective_counts.comment_lines)
6489        .sum();
6490    let blank_lines: u64 = run
6491        .per_file_records
6492        .iter()
6493        .map(|r| r.effective_counts.blank_lines)
6494        .sum();
6495    run.summary_totals.files_analyzed = files_analyzed;
6496    run.summary_totals.files_considered = files_analyzed;
6497    run.summary_totals.code_lines = code_lines;
6498    run.summary_totals.comment_lines = comment_lines;
6499    run.summary_totals.blank_lines = blank_lines;
6500    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
6501}
6502
6503fn fmt_delta(n: i64) -> String {
6504    if n > 0 {
6505        format!("+{n}")
6506    } else {
6507        format!("{n}")
6508    }
6509}
6510
6511fn delta_class(n: i64) -> &'static str {
6512    use std::cmp::Ordering;
6513    match n.cmp(&0) {
6514        Ordering::Greater => "pos",
6515        Ordering::Less => "neg",
6516        Ordering::Equal => "zero",
6517    }
6518}
6519
6520// ratio/percentage display, precision loss acceptable
6521#[allow(clippy::cast_precision_loss)]
6522fn fmt_pct(delta: i64, baseline: u64) -> String {
6523    if baseline == 0 {
6524        return "—".to_string();
6525    }
6526    #[allow(clippy::cast_precision_loss)]
6527    let pct = (delta as f64 / baseline as f64) * 100.0;
6528    if pct > 0.049 {
6529        format!("+{pct:.1}%")
6530    } else if pct < -0.049 {
6531        format!("{pct:.1}%")
6532    } else {
6533        "±0%".to_string()
6534    }
6535}
6536
6537/// Returns (`display_string`, `css_class`) for a numeric change column cell.
6538fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6539    prev.map_or_else(
6540        || ("—".to_string(), "na"),
6541        |p| {
6542            #[allow(clippy::cast_possible_wrap)]
6543            let d = curr as i64 - p as i64;
6544            (fmt_delta(d), delta_class(d))
6545        },
6546    )
6547}
6548
6549#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
6550fn load_scan_for_compare(
6551    json_path: &std::path::Path,
6552    scan_label: &str,
6553    run_id: &str,
6554    server_mode: bool,
6555    compare_url: &str,
6556    csp_nonce: &str,
6557) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6558    match read_json(json_path) {
6559        Ok(r) => Ok(r),
6560        Err(e) => {
6561            if server_mode {
6562                let html = ErrorTemplate {
6563                    message: format!(
6564                        "Could not load {scan_label} scan data. The scan output folder may have \
6565                         been moved, renamed, or deleted. Re-running the analysis will create \
6566                         fresh comparison data."
6567                    ),
6568                    last_report_url: Some("/compare-scans".to_string()),
6569                    last_report_label: Some("Compare Scans".to_string()),
6570                    run_id: Some(run_id.to_owned()),
6571                    error_code: Some(404),
6572                    csp_nonce: csp_nonce.to_owned(),
6573                    version: env!("CARGO_PKG_VERSION"),
6574                }
6575                .render()
6576                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6577                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6578            }
6579            let msg = format!(
6580                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6581                json_path.display()
6582            );
6583            let folder_hint = json_path
6584                .parent()
6585                .map(|p| p.display().to_string())
6586                .unwrap_or_default();
6587            Err(missing_scan_relocate_response(
6588                &msg,
6589                run_id,
6590                &folder_hint,
6591                compare_url,
6592                false,
6593                csp_nonce,
6594            ))
6595        }
6596    }
6597}
6598
6599struct ChurnStats {
6600    new_scope: bool,
6601    scope_flag: bool,
6602    churn_rate_str: String,
6603    churn_rate_class: String,
6604}
6605
6606fn compute_churn_stats(
6607    baseline_code: u64,
6608    current_code: u64,
6609    lines_added: i64,
6610    lines_removed: i64,
6611) -> ChurnStats {
6612    let new_scope = baseline_code == 0 && current_code > 0;
6613    #[allow(clippy::cast_precision_loss)]
6614    let churn_pct = if baseline_code > 0 {
6615        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6616    } else {
6617        0.0
6618    };
6619    #[allow(clippy::cast_precision_loss)]
6620    let scope_flag =
6621        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6622    let churn_rate_str = if new_scope {
6623        "New".to_string()
6624    } else if baseline_code > 0 {
6625        format!("{churn_pct:.1}%")
6626    } else {
6627        "—".to_string()
6628    };
6629    let churn_rate_class = if new_scope || churn_pct > 20.0 {
6630        "high".to_string()
6631    } else if churn_pct > 5.0 {
6632        "med".to_string()
6633    } else {
6634        "low".to_string()
6635    };
6636    ChurnStats {
6637        new_scope,
6638        scope_flag,
6639        churn_rate_str,
6640        churn_rate_class,
6641    }
6642}
6643
6644/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
6645/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
6646/// variables to the large CompareTemplate, which causes rustc stack overflows on Windows.
6647fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
6648    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
6649    if !has_data {
6650        return String::new();
6651    }
6652    let base_str = s
6653        .baseline_coverage_line_pct
6654        .map(|p| format!("{p:.1}%"))
6655        .unwrap_or_else(|| "\u{2014}".into());
6656    let curr_str = s
6657        .current_coverage_line_pct
6658        .map(|p| format!("{p:.1}%"))
6659        .unwrap_or_else(|| "\u{2014}".into());
6660    let (delta_str, cls) = match s.coverage_line_pct_delta {
6661        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
6662        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
6663        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
6664        None => ("\u{2014}".into(), "zero"),
6665    };
6666    format!(
6667        r#"<div class="delta-card">
6668          <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>
6669          <div class="delta-card-label">Line coverage</div>
6670          <div class="delta-card-from">Before: {base_str}</div>
6671          <div class="delta-card-to">{curr_str}</div>
6672          <span class="delta-card-change {cls}">{delta_str}</span>
6673        </div>"#
6674    )
6675}
6676
6677#[allow(clippy::too_many_lines)]
6678async fn compare_handler(
6679    State(state): State<AppState>,
6680    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6681    Query(query): Query<CompareQuery>,
6682) -> impl IntoResponse {
6683    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
6684    // redirect to the history page where the user can select two runs.
6685    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
6686        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
6687        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
6688    };
6689
6690    let (maybe_a, maybe_b) = {
6691        let reg = state.registry.lock().await;
6692        (
6693            reg.find_by_run_id(&run_id_a).cloned(),
6694            reg.find_by_run_id(&run_id_b).cloned(),
6695        )
6696    };
6697
6698    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
6699        let html = ErrorTemplate {
6700            message: "One or both run IDs were not found in scan history. \
6701                      The runs may have been deleted or the registry may have been reset."
6702                .to_string(),
6703            last_report_url: Some("/compare-scans".to_string()),
6704            last_report_label: Some("Compare Scans".to_string()),
6705            run_id: None,
6706            error_code: None,
6707            csp_nonce: csp_nonce.clone(),
6708            version: env!("CARGO_PKG_VERSION"),
6709        }
6710        .render()
6711        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
6712        return Html(html).into_response();
6713    };
6714
6715    // Ensure older scan is always the baseline.
6716    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
6717        (entry_a, entry_b)
6718    } else {
6719        (entry_b, entry_a)
6720    };
6721
6722    // If query params were in the wrong order, redirect to canonical URL so the
6723    // browser always shows the same URL for the same two scans regardless of how
6724    // the user arrived here (Full diff button vs. Compare Scans selection).
6725    if baseline_entry.run_id != run_id_a {
6726        let canonical = format!(
6727            "/compare?a={}&b={}",
6728            baseline_entry.run_id, current_entry.run_id
6729        );
6730        return axum::response::Redirect::to(&canonical).into_response();
6731    }
6732
6733    let (Some(base_json), Some(curr_json)) = (
6734        baseline_entry.json_path.as_ref(),
6735        current_entry.json_path.as_ref(),
6736    ) else {
6737        let html = ErrorTemplate {
6738            message: "Full comparison requires JSON scan data, which was not saved for one or \
6739                      both of these runs. JSON is now always saved for new scans — re-run the \
6740                      affected projects to enable comparisons."
6741                .to_string(),
6742            last_report_url: Some("/compare-scans".to_string()),
6743            last_report_label: Some("Compare Scans".to_string()),
6744            run_id: None,
6745            error_code: None,
6746            csp_nonce: csp_nonce.clone(),
6747            version: env!("CARGO_PKG_VERSION"),
6748        }
6749        .render()
6750        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
6751        return Html(html).into_response();
6752    };
6753
6754    let compare_url = format!(
6755        "/compare?a={}&b={}",
6756        baseline_entry.run_id, current_entry.run_id
6757    );
6758
6759    let baseline_run = match load_scan_for_compare(
6760        base_json,
6761        "baseline",
6762        &baseline_entry.run_id,
6763        state.server_mode,
6764        &compare_url,
6765        &csp_nonce,
6766    ) {
6767        Ok(r) => r,
6768        Err(resp) => return resp,
6769    };
6770    let current_run = match load_scan_for_compare(
6771        curr_json,
6772        "current",
6773        &current_entry.run_id,
6774        state.server_mode,
6775        &compare_url,
6776        &csp_nonce,
6777    ) {
6778        Ok(r) => r,
6779        Err(resp) => return resp,
6780    };
6781
6782    let active_submodule = query.sub.clone();
6783    let super_scope_active = query.scope.as_deref() == Some("super");
6784
6785    let submodule_options = baseline_run
6786        .submodule_summaries
6787        .iter()
6788        .chain(current_run.submodule_summaries.iter())
6789        .map(|s| s.name.clone())
6790        .collect::<std::collections::BTreeSet<_>>()
6791        .into_iter()
6792        .collect::<Vec<_>>();
6793    let has_any_submodule_data = !submodule_options.is_empty();
6794
6795    // Narrow per_file_records when a scope is active, then recompute totals.
6796    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
6797        let mut b = baseline_run;
6798        let mut c = current_run;
6799        b.per_file_records
6800            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6801        c.per_file_records
6802            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
6803        recompute_summary_from_records(&mut b);
6804        recompute_summary_from_records(&mut c);
6805        (b, c)
6806    } else if super_scope_active {
6807        let mut b = baseline_run;
6808        let mut c = current_run;
6809        b.per_file_records.retain(|f| f.submodule.is_none());
6810        c.per_file_records.retain(|f| f.submodule.is_none());
6811        recompute_summary_from_records(&mut b);
6812        recompute_summary_from_records(&mut c);
6813        (b, c)
6814    } else {
6815        (baseline_run, current_run)
6816    };
6817
6818    let comparison = compute_delta(&effective_baseline, &effective_current);
6819
6820    let file_rows: Vec<CompareFileDeltaRow> = comparison
6821        .file_deltas
6822        .iter()
6823        .map(|d| CompareFileDeltaRow {
6824            relative_path: d.relative_path.clone(),
6825            language: d.language.clone().unwrap_or_else(|| "—".into()),
6826            status: match d.status {
6827                FileChangeStatus::Added => "added".into(),
6828                FileChangeStatus::Removed => "removed".into(),
6829                FileChangeStatus::Modified => "modified".into(),
6830                FileChangeStatus::Unchanged => "unchanged".into(),
6831            },
6832            baseline_code: d.baseline_code,
6833            current_code: d.current_code,
6834            code_delta_str: fmt_delta(d.code_delta),
6835            code_delta_class: delta_class(d.code_delta).into(),
6836            comment_delta_str: fmt_delta(d.comment_delta),
6837            comment_delta_class: delta_class(d.comment_delta).into(),
6838            total_delta_str: fmt_delta(d.total_delta),
6839            total_delta_class: delta_class(d.total_delta).into(),
6840        })
6841        .collect();
6842
6843    let project_path = baseline_entry
6844        .input_roots
6845        .first()
6846        .map(|s| sanitize_path_str(s))
6847        .unwrap_or_default();
6848    let lines_added = sum_added_code_lines(&comparison);
6849    let lines_removed = sum_removed_code_lines(&comparison);
6850    let churn = compute_churn_stats(
6851        comparison.summary.baseline_code,
6852        comparison.summary.current_code,
6853        lines_added,
6854        lines_removed,
6855    );
6856    let s = &comparison.summary;
6857    let template = CompareTemplate {
6858        version: env!("CARGO_PKG_VERSION"),
6859        project_label: baseline_entry.project_label.clone(),
6860        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
6861        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
6862        baseline_run_id: baseline_entry.run_id.clone(),
6863        current_run_id: current_entry.run_id.clone(),
6864        baseline_run_id_short: baseline_entry
6865            .run_id
6866            .split('-')
6867            .next_back()
6868            .unwrap_or(&baseline_entry.run_id)
6869            .chars()
6870            .take(7)
6871            .collect(),
6872        current_run_id_short: current_entry
6873            .run_id
6874            .split('-')
6875            .next_back()
6876            .unwrap_or(&current_entry.run_id)
6877            .chars()
6878            .take(7)
6879            .collect(),
6880        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
6881        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
6882        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
6883        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
6884        project_path: project_path.clone(),
6885        baseline_code: s.baseline_code,
6886        current_code: s.current_code,
6887        code_lines_delta_str: fmt_delta(s.code_lines_delta),
6888        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
6889        baseline_files: s.baseline_files,
6890        current_files: s.current_files,
6891        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
6892        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
6893        baseline_comments: s.baseline_comments,
6894        current_comments: s.current_comments,
6895        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
6896        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
6897        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
6898        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
6899        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
6900        code_lines_added: lines_added,
6901        code_lines_removed: lines_removed,
6902        new_scope: churn.new_scope,
6903        churn_rate_str: churn.churn_rate_str,
6904        churn_rate_class: churn.churn_rate_class,
6905        scope_flag: churn.scope_flag,
6906        files_added: comparison.files_added,
6907        files_removed: comparison.files_removed,
6908        files_modified: comparison.files_modified,
6909        files_unchanged: comparison.files_unchanged,
6910        file_rows,
6911        baseline_git_author: baseline_entry.git_author.clone(),
6912        current_git_author: current_entry.git_author.clone(),
6913        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
6914        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
6915        baseline_git_tags: baseline_entry.git_tags.clone(),
6916        current_git_tags: current_entry.git_tags.clone(),
6917        baseline_git_commit_date: baseline_entry
6918            .git_commit_date
6919            .as_deref()
6920            .and_then(fmt_git_date),
6921        current_git_commit_date: current_entry
6922            .git_commit_date
6923            .as_deref()
6924            .and_then(fmt_git_date),
6925        project_name: project_path
6926            .rsplit(['/', '\\'])
6927            .find(|s| !s.is_empty())
6928            .unwrap_or(&project_path)
6929            .to_string(),
6930        submodule_options,
6931        has_any_submodule_data,
6932        active_submodule,
6933        super_scope_active,
6934        csp_nonce,
6935        coverage_delta_card: build_coverage_delta_card(s),
6936    };
6937
6938    Html(
6939        template
6940            .render()
6941            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6942    )
6943    .into_response()
6944}
6945
6946// ── Badge endpoint ────────────────────────────────────────────────────────────
6947// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
6948// pages, Jira descriptions, etc.
6949//
6950// GET /badge/<metric>?label=<override>&color=<hex>
6951// Metrics: code-lines  files  comment-lines  blank-lines
6952
6953fn format_number(n: u64) -> String {
6954    let s = n.to_string();
6955    let mut out = String::with_capacity(s.len() + s.len() / 3);
6956    let len = s.len();
6957    for (i, c) in s.chars().enumerate() {
6958        if i > 0 && (len - i).is_multiple_of(3) {
6959            out.push(',');
6960        }
6961        out.push(c);
6962    }
6963    out
6964}
6965
6966const fn badge_char_width(c: char) -> f64 {
6967    match c {
6968        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
6969        'm' | 'w' => 9.0,
6970        ' ' => 4.0,
6971        _ => 6.5,
6972    }
6973}
6974
6975#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6976fn badge_text_px(text: &str) -> u32 {
6977    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
6978}
6979
6980fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
6981    let lw = badge_text_px(label) + 20;
6982    let rw = badge_text_px(value) + 20;
6983    let total = lw + rw;
6984    let lx = lw / 2;
6985    let rx = lw + rw / 2;
6986    let le = escape_html(label);
6987    let ve = escape_html(value);
6988    let ce = escape_html(color);
6989    format!(
6990        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
6991  <rect width="{total}" height="20" fill="#555"/>
6992  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
6993  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
6994    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
6995    <text x="{lx}" y="13">{le}</text>
6996    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
6997    <text x="{rx}" y="13">{ve}</text>
6998  </g>
6999</svg>"##
7000    )
7001}
7002
7003#[derive(Deserialize)]
7004struct BadgeQuery {
7005    label: Option<String>,
7006    color: Option<String>,
7007}
7008
7009async fn badge_handler(
7010    State(state): State<AppState>,
7011    AxumPath(metric): AxumPath<String>,
7012    Query(query): Query<BadgeQuery>,
7013) -> Response {
7014    let entry = {
7015        let reg = state.registry.lock().await;
7016        reg.entries.first().cloned()
7017    };
7018
7019    let Some(entry) = entry else {
7020        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7021        return (
7022            [
7023                (header::CONTENT_TYPE, "image/svg+xml"),
7024                (header::CACHE_CONTROL, "no-cache, max-age=0"),
7025            ],
7026            svg,
7027        )
7028            .into_response();
7029    };
7030
7031    let (default_label, value, default_color) = match metric.as_str() {
7032        "code-lines" => (
7033            "code lines",
7034            format_number(entry.summary.code_lines),
7035            "#4a78ee",
7036        ),
7037        "files" => (
7038            "files analyzed",
7039            format_number(entry.summary.files_analyzed),
7040            "#4a9862",
7041        ),
7042        "comment-lines" => (
7043            "comment lines",
7044            format_number(entry.summary.comment_lines),
7045            "#b35428",
7046        ),
7047        "blank-lines" => (
7048            "blank lines",
7049            format_number(entry.summary.blank_lines),
7050            "#7a5db0",
7051        ),
7052        _ => return StatusCode::NOT_FOUND.into_response(),
7053    };
7054
7055    let label = query.label.as_deref().unwrap_or(default_label);
7056    let color = query.color.as_deref().unwrap_or(default_color);
7057    let svg = render_badge_svg(label, &value, color);
7058
7059    (
7060        [
7061            (header::CONTENT_TYPE, "image/svg+xml"),
7062            (header::CACHE_CONTROL, "no-cache, max-age=0"),
7063        ],
7064        svg,
7065    )
7066        .into_response()
7067}
7068
7069// ── Metrics API ───────────────────────────────────────────────────────────────
7070// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
7071// Confluence automation, Jira webhooks, etc.
7072//
7073// GET /api/metrics/latest
7074// GET /api/metrics/<run_id>
7075
7076#[derive(Serialize)]
7077struct ApiCoverageBlock {
7078    lines_found: u64,
7079    lines_hit: u64,
7080    line_pct: f64,
7081    functions_found: u64,
7082    functions_hit: u64,
7083    function_pct: f64,
7084    branches_found: u64,
7085    branches_hit: u64,
7086    branch_pct: f64,
7087}
7088
7089#[derive(Serialize)]
7090struct ApiMetricsResponse {
7091    run_id: String,
7092    timestamp: String,
7093    project: String,
7094    summary: ApiSummaryPayload,
7095    languages: Vec<ApiLanguageRow>,
7096    #[serde(skip_serializing_if = "Option::is_none")]
7097    coverage: Option<ApiCoverageBlock>,
7098}
7099
7100#[derive(Serialize)]
7101struct ApiSummaryPayload {
7102    files_analyzed: u64,
7103    files_skipped: u64,
7104    code_lines: u64,
7105    comment_lines: u64,
7106    blank_lines: u64,
7107    total_physical_lines: u64,
7108    functions: u64,
7109    classes: u64,
7110    variables: u64,
7111    imports: u64,
7112}
7113
7114#[derive(Serialize)]
7115struct ApiLanguageRow {
7116    name: String,
7117    files: u64,
7118    code_lines: u64,
7119    comment_lines: u64,
7120    blank_lines: u64,
7121    functions: u64,
7122    classes: u64,
7123    variables: u64,
7124    imports: u64,
7125}
7126
7127async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7128    let entry = {
7129        let reg = state.registry.lock().await;
7130        reg.entries.first().cloned()
7131    };
7132    entry.map_or_else(
7133        || error::not_found("no scans recorded yet"),
7134        |e| build_metrics_response(&e),
7135    )
7136}
7137
7138async fn api_metrics_run_handler(
7139    State(state): State<AppState>,
7140    AxumPath(run_id): AxumPath<String>,
7141) -> Response {
7142    let entry = {
7143        let reg = state.registry.lock().await;
7144        reg.find_by_run_id(&run_id).cloned()
7145    };
7146    entry.map_or_else(
7147        || error::not_found("run not found"),
7148        |e| build_metrics_response(&e),
7149    )
7150}
7151
7152fn build_metrics_response(entry: &RegistryEntry) -> Response {
7153    let languages: Vec<ApiLanguageRow> = entry
7154        .json_path
7155        .as_ref()
7156        .and_then(|p| read_json(p).ok())
7157        .map(|run| {
7158            run.totals_by_language
7159                .iter()
7160                .map(|l| ApiLanguageRow {
7161                    name: l.language.display_name().to_string(),
7162                    files: l.files,
7163                    code_lines: l.code_lines,
7164                    comment_lines: l.comment_lines,
7165                    blank_lines: l.blank_lines,
7166                    functions: l.functions,
7167                    classes: l.classes,
7168                    variables: l.variables,
7169                    imports: l.imports,
7170                })
7171                .collect()
7172        })
7173        .unwrap_or_default();
7174
7175    let s = &entry.summary;
7176    let coverage = if s.coverage_lines_found > 0 {
7177        let pct = |hit: u64, found: u64| -> f64 {
7178            if found == 0 {
7179                0.0
7180            } else {
7181                #[allow(clippy::cast_precision_loss)]
7182                let v = (hit as f64 / found as f64) * 100.0;
7183                (v * 10.0).round() / 10.0
7184            }
7185        };
7186        Some(ApiCoverageBlock {
7187            lines_found: s.coverage_lines_found,
7188            lines_hit: s.coverage_lines_hit,
7189            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7190            functions_found: s.coverage_functions_found,
7191            functions_hit: s.coverage_functions_hit,
7192            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7193            branches_found: s.coverage_branches_found,
7194            branches_hit: s.coverage_branches_hit,
7195            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7196        })
7197    } else {
7198        None
7199    };
7200    Json(ApiMetricsResponse {
7201        run_id: entry.run_id.clone(),
7202        timestamp: entry.timestamp_utc.to_rfc3339(),
7203        project: entry.project_label.clone(),
7204        summary: ApiSummaryPayload {
7205            files_analyzed: s.files_analyzed,
7206            files_skipped: s.files_skipped,
7207            code_lines: s.code_lines,
7208            comment_lines: s.comment_lines,
7209            blank_lines: s.blank_lines,
7210            total_physical_lines: s.total_physical_lines,
7211            functions: s.functions,
7212            classes: s.classes,
7213            variables: s.variables,
7214            imports: s.imports,
7215        },
7216        languages,
7217        coverage,
7218    })
7219    .into_response()
7220}
7221
7222// ── Project history API ───────────────────────────────────────────────────────
7223// Protected. Called by the wizard JS when the project path changes, so the UI
7224// can show a "scanned N times before" badge without a full page reload.
7225//
7226// GET /api/project-history?path=<project_root>
7227
7228#[derive(Deserialize)]
7229struct ProjectHistoryQuery {
7230    path: Option<String>,
7231}
7232
7233#[derive(Serialize)]
7234struct ProjectHistoryResponse {
7235    scan_count: usize,
7236    last_scan_id: Option<String>,
7237    last_scan_timestamp: Option<String>,
7238    last_scan_code_lines: Option<u64>,
7239    last_git_branch: Option<String>,
7240    last_git_commit: Option<String>,
7241}
7242
7243/// Return true if `entry` matches either an exact root path or an upload-staging
7244/// path with the same project name (needed because each upload gets a fresh UUID dir).
7245fn entry_matches_project(
7246    entry: &RegistryEntry,
7247    root_str: &str,
7248    upload_root: &str,
7249    upload_name_suffix: Option<&str>,
7250) -> bool {
7251    if entry.input_roots.iter().any(|r| r == root_str) {
7252        return true;
7253    }
7254    if let Some(suffix) = upload_name_suffix {
7255        return entry
7256            .input_roots
7257            .iter()
7258            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7259    }
7260    false
7261}
7262
7263async fn project_history_handler(
7264    State(state): State<AppState>,
7265    Query(query): Query<ProjectHistoryQuery>,
7266) -> Response {
7267    let path = query.path.unwrap_or_default();
7268    let resolved = resolve_input_path(&path);
7269    let root_str = resolved.to_string_lossy().replace('\\', "/");
7270
7271    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
7272    // The UUID is freshly generated for every upload, so an exact root_str match never finds
7273    // previous scans of the same project. Fall back to matching by project name within the
7274    // uploads staging directory so Scan History populates correctly across uploads.
7275    let upload_root = std::env::temp_dir()
7276        .join("oxide-sloc-uploads")
7277        .to_string_lossy()
7278        .replace('\\', "/");
7279    let upload_name_suffix: Option<String> =
7280        if state.server_mode && root_str.starts_with(&upload_root) {
7281            resolved
7282                .file_name()
7283                .and_then(|n| n.to_str())
7284                .map(|name| format!("/{name}"))
7285        } else {
7286            None
7287        };
7288    let suffix_ref = upload_name_suffix.as_deref();
7289
7290    let entries: Vec<_> = {
7291        let reg = state.registry.lock().await;
7292        reg.entries
7293            .iter()
7294            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7295            .cloned()
7296            .collect()
7297    };
7298    let scan_count = entries.len();
7299    let last = entries.first();
7300    let last_scan_id = last.map(|e| e.run_id.clone());
7301    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7302    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7303    let last_git_branch = last.and_then(|e| e.git_branch.clone());
7304    let last_git_commit = last.and_then(|e| e.git_commit.clone());
7305
7306    Json(ProjectHistoryResponse {
7307        scan_count,
7308        last_scan_id,
7309        last_scan_timestamp,
7310        last_scan_code_lines,
7311        last_git_branch,
7312        last_git_commit,
7313    })
7314    .into_response()
7315}
7316
7317// ── Metrics history API ───────────────────────────────────────────────────────
7318// Protected. Returns a JSON array of lightweight scan snapshots for plotting
7319// trend charts.
7320//
7321// GET /api/metrics/history?root=<path>&limit=<n>
7322
7323#[derive(Deserialize)]
7324struct MetricsHistoryQuery {
7325    root: Option<String>,
7326    limit: Option<usize>,
7327    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
7328    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
7329    submodule: Option<String>,
7330}
7331
7332#[derive(Serialize)]
7333struct MetricsSubmoduleLink {
7334    name: String,
7335    url: String,
7336}
7337
7338#[derive(Serialize)]
7339struct MetricsHistoryEntry {
7340    run_id: String,
7341    run_id_short: String,
7342    timestamp: String,
7343    commit: Option<String>,
7344    branch: Option<String>,
7345    tags: Vec<String>,
7346    nearest_tag: Option<String>,
7347    code_lines: u64,
7348    comment_lines: u64,
7349    blank_lines: u64,
7350    physical_lines: u64,
7351    files_analyzed: u64,
7352    files_skipped: u64,
7353    test_count: u64,
7354    project_label: String,
7355    html_url: Option<String>,
7356    has_pdf: bool,
7357    submodule_links: Vec<MetricsSubmoduleLink>,
7358    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
7359    #[serde(skip_serializing_if = "Option::is_none")]
7360    coverage_line_pct: Option<f64>,
7361}
7362
7363fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7364    let mut links: Vec<MetricsSubmoduleLink> = vec![];
7365    let sub_dir = e
7366        .html_path
7367        .as_ref()
7368        .and_then(|p| p.parent())
7369        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7370    let Some(dir) = sub_dir else { return links };
7371    let Ok(rd) = std::fs::read_dir(dir) else {
7372        return links;
7373    };
7374    for entry_res in rd.flatten() {
7375        let fname = entry_res.file_name();
7376        let fname_str = fname.to_string_lossy();
7377        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7378            let stem = &fname_str[..fname_str.len() - 5];
7379            let display = stem[4..].replace('-', " ");
7380            links.push(MetricsSubmoduleLink {
7381                name: display,
7382                url: format!("/runs/{stem}/{}", e.run_id),
7383            });
7384        }
7385    }
7386    links.sort_by(|a, b| a.name.cmp(&b.name));
7387    links
7388}
7389
7390fn apply_submodule_filter(
7391    base: MetricsHistoryEntry,
7392    filter: &str,
7393    e: &sloc_core::history::RegistryEntry,
7394) -> Option<MetricsHistoryEntry> {
7395    let json_path = e.json_path.as_ref()?;
7396    let json_str = std::fs::read_to_string(json_path).ok()?;
7397    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7398    let sub = run
7399        .submodule_summaries
7400        .iter()
7401        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7402    let safe = sanitize_project_label(&sub.name);
7403    let artifact_key = format!("sub_{safe}");
7404    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7405        || base.html_url.clone(),
7406        |run_dir| {
7407            let sub_path = run_dir.join(format!("{artifact_key}.html"));
7408            if sub_path.exists() {
7409                Some(format!("/runs/{artifact_key}/{}", e.run_id))
7410            } else {
7411                base.html_url.clone()
7412            }
7413        },
7414    );
7415    Some(MetricsHistoryEntry {
7416        code_lines: sub.code_lines,
7417        comment_lines: sub.comment_lines,
7418        blank_lines: sub.blank_lines,
7419        physical_lines: sub.total_physical_lines,
7420        files_analyzed: sub.files_analyzed,
7421        html_url: sub_html_url,
7422        has_pdf: false,
7423        submodule_links: vec![],
7424        ..base
7425    })
7426}
7427
7428#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
7429async fn api_metrics_history_handler(
7430    State(state): State<AppState>,
7431    Query(query): Query<MetricsHistoryQuery>,
7432) -> Response {
7433    let limit = query.limit.unwrap_or(50).min(500);
7434    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7435
7436    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7437        let reg = state.registry.lock().await;
7438        reg.entries
7439            .iter()
7440            .filter(|e| {
7441                query.root.as_ref().is_none_or(|root| {
7442                    let resolved = resolve_input_path(root);
7443                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7444                    e.input_roots.iter().any(|r| r == &root_str)
7445                })
7446            })
7447            .take(limit)
7448            .cloned()
7449            .collect()
7450    };
7451
7452    let entries: Vec<MetricsHistoryEntry> = candidate_entries
7453        .into_iter()
7454        .filter_map(|e| {
7455            let tags = e
7456                .git_tags
7457                .as_deref()
7458                .map(|s| {
7459                    s.split(',')
7460                        .map(|t| t.trim().to_string())
7461                        .filter(|t| !t.is_empty())
7462                        .collect()
7463                })
7464                .unwrap_or_default();
7465            let html_url = e
7466                .html_path
7467                .as_ref()
7468                .filter(|p| p.exists())
7469                .map(|_| format!("/runs/html/{}", e.run_id));
7470            let nearest_tag = e.git_nearest_tag.clone();
7471            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7472            let run_id_short: String = e
7473                .run_id
7474                .split('-')
7475                .next_back()
7476                .unwrap_or(&e.run_id)
7477                .chars()
7478                .take(7)
7479                .collect();
7480            let submodule_links = build_entry_submodule_links(&e);
7481            #[allow(clippy::cast_precision_loss)]
7482            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7483                let pct = (e.summary.coverage_lines_hit as f64
7484                    / e.summary.coverage_lines_found as f64)
7485                    * 100.0;
7486                Some((pct * 10.0).round() / 10.0)
7487            } else {
7488                None
7489            };
7490            let base = MetricsHistoryEntry {
7491                run_id: e.run_id.clone(),
7492                run_id_short,
7493                timestamp: e.timestamp_utc.to_rfc3339(),
7494                commit: e.git_commit.clone(),
7495                branch: e.git_branch.clone(),
7496                tags,
7497                nearest_tag,
7498                code_lines: e.summary.code_lines,
7499                comment_lines: e.summary.comment_lines,
7500                blank_lines: e.summary.blank_lines,
7501                physical_lines: e.summary.total_physical_lines,
7502                files_analyzed: e.summary.files_analyzed,
7503                files_skipped: e.summary.files_skipped,
7504                test_count: e.summary.test_count,
7505                project_label: e.project_label.clone(),
7506                html_url,
7507                has_pdf,
7508                submodule_links,
7509                coverage_line_pct,
7510            };
7511            if let Some(ref filter) = submodule_filter {
7512                apply_submodule_filter(base, filter, &e)
7513            } else {
7514                Some(base)
7515            }
7516        })
7517        .collect();
7518
7519    Json(entries).into_response()
7520}
7521
7522// GET /api/metrics/submodules?root=<path>
7523// Returns the union of distinct submodule names found across all saved scan JSON artifacts
7524// for the given project root (or all roots if omitted).
7525#[derive(Deserialize)]
7526struct MetricsSubmodulesQuery {
7527    root: Option<String>,
7528}
7529
7530#[derive(Serialize)]
7531struct SubmoduleEntry {
7532    name: String,
7533    relative_path: String,
7534}
7535
7536async fn api_metrics_submodules_handler(
7537    State(state): State<AppState>,
7538    Query(query): Query<MetricsSubmodulesQuery>,
7539) -> Response {
7540    let json_paths: Vec<std::path::PathBuf> = {
7541        let reg = state.registry.lock().await;
7542        reg.entries
7543            .iter()
7544            .filter(|e| {
7545                query.root.as_ref().is_none_or(|root| {
7546                    let resolved = resolve_input_path(root);
7547                    let root_str = resolved.to_string_lossy().replace('\\', "/");
7548                    e.input_roots.iter().any(|r| r == &root_str)
7549                })
7550            })
7551            .filter_map(|e| e.json_path.clone())
7552            .collect()
7553    };
7554
7555    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7556    let mut result: Vec<SubmoduleEntry> = Vec::new();
7557
7558    for path in &json_paths {
7559        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7560            continue;
7561        };
7562        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
7563            continue;
7564        };
7565        for sub in &run.submodule_summaries {
7566            if seen.insert(sub.name.clone()) {
7567                result.push(SubmoduleEntry {
7568                    name: sub.name.clone(),
7569                    relative_path: sub.relative_path.clone(),
7570                });
7571            }
7572        }
7573    }
7574
7575    result.sort_by(|a, b| a.name.cmp(&b.name));
7576    Json(result).into_response()
7577}
7578
7579// ── CI ingest endpoint ────────────────────────────────────────────────────────
7580// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
7581// server stores and displays results without cloning or scanning anything itself.
7582//
7583// POST /api/ingest?label=<optional_display_name>
7584// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
7585// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
7586
7587#[derive(Deserialize)]
7588struct IngestQuery {
7589    label: Option<String>,
7590}
7591
7592#[derive(Serialize)]
7593struct IngestResponse {
7594    run_id: String,
7595    view_url: String,
7596}
7597
7598async fn api_ingest_handler(
7599    State(state): State<AppState>,
7600    Query(q): Query<IngestQuery>,
7601    Json(run): Json<sloc_core::AnalysisRun>,
7602) -> Response {
7603    let label = q.label.unwrap_or_else(|| {
7604        run.input_roots
7605            .first()
7606            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
7607    });
7608
7609    let label_for_task = label.clone();
7610    let result = tokio::task::spawn_blocking(move || {
7611        let html = render_html(&run)?;
7612        let run_id = run.tool.run_id.clone();
7613        let run_id_safe = run_id.len() <= 128
7614            && !run_id.is_empty()
7615            && run_id
7616                .chars()
7617                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
7618        if !run_id_safe {
7619            anyhow::bail!(
7620                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
7621            );
7622        }
7623        let project_label = sanitize_project_label(&label_for_task);
7624        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
7625        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
7626            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
7627            _ => project_label,
7628        };
7629        let (artifacts, _pending_pdf) = persist_run_artifacts(
7630            &run,
7631            &html,
7632            &output_dir,
7633            &label_for_task,
7634            &file_stem,
7635            RunResultContext::default(),
7636        )?;
7637        Ok::<_, anyhow::Error>((run_id, artifacts, run))
7638    })
7639    .await;
7640
7641    match result {
7642        Ok(Ok((run_id, artifacts, run))) => {
7643            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
7644            (
7645                StatusCode::CREATED,
7646                Json(IngestResponse {
7647                    view_url: format!("/view-reports?run_id={run_id}"),
7648                    run_id,
7649                }),
7650            )
7651                .into_response()
7652        }
7653        Ok(Err(e)) => error::internal(&format!("{e:#}")),
7654        Err(e) => error::internal(&format!("{e}")),
7655    }
7656}
7657
7658// ── Trend report page ─────────────────────────────────────────────────────────
7659// Protected. Interactive time-series chart page that loads scan history via
7660// /api/metrics/history and renders a vanilla-SVG line chart.
7661//
7662// GET /trend-reports
7663
7664#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
7665async fn trend_report_handler(
7666    State(state): State<AppState>,
7667    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7668) -> Response {
7669    auto_scan_watched_dirs(&state).await;
7670
7671    let watched_dirs_list: Vec<String> = {
7672        let wd = state.watched_dirs.lock().await;
7673        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7674    };
7675
7676    // Collect distinct project roots for the root selector dropdown.
7677    let roots: Vec<String> = {
7678        let reg = state.registry.lock().await;
7679        let mut seen = std::collections::BTreeSet::new();
7680        reg.entries
7681            .iter()
7682            .flat_map(|e| e.input_roots.iter().cloned())
7683            .filter(|r| seen.insert(r.clone()))
7684            .collect()
7685    };
7686
7687    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
7688    let nonce = &csp_nonce;
7689    let version = env!("CARGO_PKG_VERSION");
7690
7691    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
7692    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
7693    // of interactive controls — folder watching is managed by the host administrator.
7694    let watched_dirs_html: String = if state.server_mode {
7695        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()
7696    } else {
7697        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7698            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7699                .to_string()
7700        } else {
7701            watched_dirs_list
7702                .iter()
7703                .fold(String::new(), |mut s, d| {
7704                    use std::fmt::Write as _;
7705                    let escaped =
7706                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
7707                    write!(
7708                        s,
7709                        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>"#
7710                    ).expect("write to String is infallible");
7711                    s
7712                })
7713        };
7714        format!(
7715            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>"#
7716        )
7717    };
7718
7719    let html = format!(
7720        r##"<!doctype html>
7721<html lang="en">
7722<head>
7723  <meta charset="utf-8" />
7724  <meta name="viewport" content="width=device-width, initial-scale=1" />
7725  <title>OxideSLOC | Trend Reports</title>
7726  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7727  <style nonce="{nonce}">
7728    :root {{
7729      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7730      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7731      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7732      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7733      --info-bg:#eef3ff; --info-text:#4467d8;
7734    }}
7735    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7736    *{{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;}}
7737    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7738    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7739    .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;}}
7740    @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));}}}}
7741    .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);}}
7742    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7743    .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));}}
7744    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7745    .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;}}
7746    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7747    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7748    @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; }} }}
7749    .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;}}
7750    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7751    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7752    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7753    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7754    .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;}}
7755    .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;}}
7756    .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;}}
7757    .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;}}
7758    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7759    .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);}}
7760    .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;}}
7761    .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;}}
7762    .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;}}
7763    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7764    .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;}}
7765    .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);}}
7766    .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;}}
7767    .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;}}
7768    .tz-select:focus{{border-color:var(--oxide);}}
7769    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
7770    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7771    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7772    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7773    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
7774    .trend-title-block{{flex:1;min-width:0;}}
7775    .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;}}
7776    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
7777    .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;}}
7778    .chart-select:focus{{border-color:var(--accent);}}
7779    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7780    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7781    .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;}}
7782    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7783    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7784    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7785    .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);}}
7786    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7787    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7788    .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;}}
7789    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
7790    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
7791    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
7792    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7793    .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;}}
7794    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
7795    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
7796    .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);}}
7797    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
7798    .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;}}
7799    .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;}}
7800    .data-table tr:last-child td{{border-bottom:none;}}
7801    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
7802    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
7803    .table-wrap{{width:100%;overflow-x:auto;}}
7804    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
7805    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
7806    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
7807    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
7808    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
7809    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
7810    .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;}}
7811    .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;}}
7812    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
7813    .pagination-info{{font-size:13px;color:var(--muted);}}
7814    .pagination-btns{{display:flex;gap:6px;}}
7815    .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;}}
7816    .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;}}
7817    #scan-history-table col:nth-child(1){{width:155px;}}
7818    #scan-history-table col:nth-child(2){{width:240px;}}
7819    #scan-history-table col:nth-child(3){{width:82px;}}
7820    #scan-history-table col:nth-child(4){{width:82px;}}
7821    #scan-history-table col:nth-child(5){{width:90px;}}
7822    #scan-history-table col:nth-child(6){{width:90px;}}
7823    #scan-history-table col:nth-child(7){{width:88px;}}
7824    #scan-history-table col:nth-child(8){{width:150px;}}
7825    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
7826    .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;}}
7827    .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;}}
7828    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
7829    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
7830    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7831    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7832    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7833    .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;}}
7834    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7835    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7836    .watched-chip-rm:hover{{color:var(--oxide);}}
7837    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7838    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7839    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7840    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7841    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
7842    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
7843    a.run-link:hover{{text-decoration:underline;}}
7844    .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);}}
7845    .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);}}
7846    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
7847    .metric-num{{font-weight:700;color:var(--text);}}
7848    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
7849    .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;}}
7850    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
7851    .btn.primary:hover{{opacity:.9;}}
7852    .rpt-btn{{min-width:58px;justify-content:center;}}
7853    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
7854    .report-cell{{overflow:visible!important;white-space:normal!important;}}
7855    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
7856    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
7857    .submod-details summary::-webkit-details-marker{{display:none;}}
7858    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
7859    .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;}}
7860    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
7861    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
7862    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
7863    .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;}}
7864    .export-btn:hover{{background:var(--line);}}
7865    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
7866    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7867    .site-footer a{{color:var(--muted);}}
7868    .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;}}
7869    .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;}}
7870    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
7871  </style>
7872</head>
7873<body>
7874  <div class="background-watermarks" aria-hidden="true">
7875    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7876    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7877    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7878    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7879    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7880    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7881  </div>
7882  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7883  <div class="top-nav">
7884    <div class="top-nav-inner">
7885      <a class="brand" href="/">
7886        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7887        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
7888      </a>
7889      <div class="nav-right">
7890        <a class="nav-pill" href="/">Home</a>
7891        <div class="nav-dropdown">
7892          <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>
7893          <div class="nav-dropdown-menu">
7894            <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>
7895          </div>
7896        </div>
7897        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7898        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
7899        <div class="nav-dropdown">
7900          <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>
7901          <div class="nav-dropdown-menu">
7902            <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>
7903          </div>
7904        </div>
7905        <div class="server-status-wrap" id="server-status-wrap">
7906          <div class="nav-pill server-online-pill" id="server-status-pill">
7907            <span class="status-dot" id="status-dot"></span>
7908            <span id="server-status-label">Server</span>
7909            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
7910          </div>
7911          <div class="server-status-tip">
7912            OxideSLOC is running — accessible on your network.
7913            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
7914          </div>
7915        </div>
7916        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7917          <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>
7918        </button>
7919        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7920          <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>
7921          <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>
7922        </button>
7923      </div>
7924    </div>
7925  </div>
7926
7927  <div class="page">
7928    {watched_dirs_html}
7929    <div class="summary-strip" id="trend-stats"></div>
7930    <div class="panel">
7931      <div class="trend-header">
7932        <div class="trend-title-block">
7933          <h1>Trend Reports</h1>
7934          <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>
7935          <span class="chart-hint-inline">
7936            <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>
7937            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
7938          </span>
7939        </div>
7940        <div class="chart-actions">
7941          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
7942            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
7943            Retention Policy
7944          </button>
7945          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
7946            <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>
7947            Clean up old runs
7948          </button>
7949          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
7950            <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>
7951            Export Excel
7952          </button>
7953          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
7954            <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>
7955            Export PNG
7956          </button>
7957        </div>
7958      </div>
7959
7960      <div class="controls-centered">
7961        <label>Project Root:
7962          <select class="chart-select" id="root-sel">
7963            <option value="">All projects</option>
7964          </select>
7965        </label>
7966        <label>Y Metric:
7967          <select class="chart-select" id="y-sel">
7968            <option value="code_lines">Code Lines</option>
7969            <option value="comment_lines">Comment Lines</option>
7970            <option value="blank_lines">Blank Lines</option>
7971            <option value="physical_lines">Physical Lines</option>
7972            <option value="files_analyzed">Files Analyzed</option>
7973          </select>
7974        </label>
7975        <label>X Axis:
7976          <select class="chart-select" id="x-sel">
7977            <option value="time">By Time</option>
7978            <option value="commit">By Commit</option>
7979            <option value="release">By Release</option>
7980            <option value="tag">Tagged Commits</option>
7981          </select>
7982        </label>
7983        <label id="submodule-label" style="display:none;">Submodule:
7984          <select class="chart-select" id="sub-sel">
7985            <option value="">All (project total)</option>
7986          </select>
7987        </label>
7988        <label>Chart Size:
7989          <select class="chart-select" id="scale-sel">
7990            <option value="0.75">Compact</option>
7991            <option value="1.2" selected>Normal</option>
7992            <option value="1.38">Large</option>
7993          </select>
7994        </label>
7995      </div>
7996
7997      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
7998      <div id="data-table-wrap" style="overflow-x:auto;"></div>
7999    </div>
8000  </div>
8001
8002  <script nonce="{nonce}">
8003    (function() {{
8004      // Theme persistence
8005      var b = document.body;
8006      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
8007      var tgl = document.getElementById('theme-toggle');
8008      if (tgl) tgl.addEventListener('click', function() {{
8009        var d = b.classList.toggle('dark-theme');
8010        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
8011      }});
8012
8013      // Watermark randomizer
8014      (function() {{
8015        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8016        if (!wms.length) return;
8017        var placed = [];
8018        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;}}
8019        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];}}
8020        var half=Math.floor(wms.length/2);
8021        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;}});
8022      }})();
8023
8024      // Code particles
8025      (function() {{
8026        var container = document.getElementById('code-particles');
8027        if (!container) return;
8028        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'];
8029        for (var i = 0; i < 38; i++) {{
8030          (function(idx) {{
8031            var el = document.createElement('span');
8032            el.className = 'code-particle';
8033            el.textContent = snippets[idx % snippets.length];
8034            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
8035            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
8036            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
8037            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';
8038            container.appendChild(el);
8039          }})(i);
8040        }}
8041      }})();
8042
8043      // Watched folder picker
8044      (function() {{
8045        var btn = document.getElementById('add-watched-btn');
8046        if (!btn) return;
8047        btn.addEventListener('click', function() {{
8048          fetch('/pick-directory?kind=reports')
8049            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
8050            .then(function(data) {{
8051              if (!data.cancelled && data.selected_path) {{
8052                var form = document.createElement('form');
8053                form.method = 'POST';
8054                form.action = '/watched-dirs/add';
8055                var ri = document.createElement('input');
8056                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
8057                var fi = document.createElement('input');
8058                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
8059                form.appendChild(ri); form.appendChild(fi);
8060                document.body.appendChild(form);
8061                form.submit();
8062              }}
8063            }})
8064            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
8065        }});
8066      }})();
8067
8068      // Settings / color-scheme modal
8069      (function() {{
8070        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'}}];
8071        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);}});}}
8072        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
8073        var btn=document.getElementById('settings-btn');if(!btn)return;
8074        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
8075        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>';
8076        document.body.appendChild(m);
8077        var g=document.getElementById('scheme-grid');
8078        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);}});
8079        var cl=document.getElementById('settings-close');
8080        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);
8081        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');}});
8082        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
8083        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
8084      }})();
8085    }})();
8086
8087    var ROOTS = {roots_json};
8088    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8089    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
8090    var allData = [];
8091
8092    // Populate root selector
8093    var rootSel = document.getElementById('root-sel');
8094    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
8095
8096    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();}}
8097    function fmtFull(n){{return Number(n).toLocaleString();}}
8098    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
8099
8100    // Tooltip
8101    var tt = document.createElement('div');
8102    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);';
8103    document.body.appendChild(tt);
8104    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
8105    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';}}
8106    function hideTT(){{tt.style.display='none';}}
8107
8108    function statExact(compact, full){{
8109      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
8110    }}
8111    function statVal(n){{
8112      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
8113    }}
8114
8115    function updateStats(data){{
8116      var statsEl=document.getElementById('trend-stats');
8117      if(!statsEl)return;
8118      if(!data||!data.length){{statsEl.innerHTML='';return;}}
8119      var yKey=document.getElementById('y-sel').value;
8120      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8121      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8122      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
8123      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
8124      var absDelta=Math.abs(delta);
8125      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
8126      var deltaExact=statExact(deltaCompact,deltaFull);
8127      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
8128      statsEl.innerHTML=
8129        '<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>'+
8130        '<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>'+
8131        '<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>'+
8132        '<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>';
8133    }}
8134
8135    var subSel = document.getElementById('sub-sel');
8136    var subLabel = document.getElementById('submodule-label');
8137
8138    function populateSubmodules(root){{
8139      if(!subSel||!subLabel)return;
8140      while(subSel.options.length>1)subSel.remove(1);
8141      subSel.value='';
8142      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
8143      fetch(url)
8144        .then(function(r){{return r.json();}})
8145        .then(function(subs){{
8146          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
8147          subs.forEach(function(s){{
8148            var o=document.createElement('option');
8149            o.value=s.name;
8150            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
8151            subSel.appendChild(o);
8152          }});
8153          subLabel.style.display='';
8154        }})
8155        .catch(function(){{subLabel.style.display='none';}});
8156    }}
8157
8158    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
8159
8160    function loadAndRender(){{
8161      var root = rootSel.value;
8162      var sub = subSel ? subSel.value : '';
8163      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
8164      document.getElementById('data-table-wrap').innerHTML='';
8165      var url = '/api/metrics/history?limit=100'
8166        + (root ? '&root='+encodeURIComponent(root) : '')
8167        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
8168      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
8169        allData = data;
8170        render(data);
8171        updateStats(data);
8172      }}).catch(function(){{
8173        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>';
8174      }});
8175    }}
8176
8177    function render(data){{
8178      var yKey = document.getElementById('y-sel').value;
8179      var xMode = document.getElementById('x-sel').value;
8180
8181      // Filter for tag/release mode
8182      var pts = data;
8183      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
8184
8185      // Sort oldest-first for the line chart
8186      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8187
8188      var wrap = document.getElementById('chart-wrap');
8189      if(!pts.length){{
8190        var emptyMsg = (xMode === 'tag')
8191          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
8192          : 'No scan data found for the selected filters.';
8193        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
8194        renderTable([]);
8195        return;
8196      }}
8197
8198      var scaleEl=document.getElementById('scale-sel');
8199      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
8200      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;
8201      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
8202
8203      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
8204
8205      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">';
8206      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>';
8207
8208      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
8209
8210      // Grid + Y axis ticks
8211      for(var ti=0;ti<=5;ti++){{
8212        var gy=PT+CH-Math.round(ti/5*CH);
8213        var gv=Math.round(ti/5*maxY);
8214        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
8215        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
8216      }}
8217
8218      // X axis labels (every N-th point to avoid crowding)
8219      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
8220      pts.forEach(function(d,i){{
8221        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8222        if(i%labelEvery===0||i===pts.length-1){{
8223          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)));
8224          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>';
8225        }}
8226      }});
8227
8228      // Axis label
8229      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
8230      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>';
8231      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>';
8232
8233      // Area fill + line path
8234      var pathD='';
8235      pts.forEach(function(d,i){{
8236        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8237        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8238        pathD+=(i===0?'M':'L')+x+','+y;
8239      }});
8240      if(pts.length>1){{
8241        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
8242        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
8243      }}
8244      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
8245
8246      // Data points (clickable) + permanent value labels
8247      var showLabels = pts.length <= 40;
8248      var labelEveryN = pts.length > 20 ? 2 : 1;
8249      pts.forEach(function(d,i){{
8250        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
8251        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
8252        var hasTags=d.tags&&d.tags.length>0;
8253        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
8254        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
8255        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+'"/>';
8256        if(showLabels && i%labelEveryN===0){{
8257          var lx=x, ly=y-r-5;
8258          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>';
8259        }}
8260      }});
8261
8262      svg+='</svg>';
8263      wrap.innerHTML=svg;
8264
8265      // Attach point tooltips
8266      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
8267        c.addEventListener('mouseover',function(e){{
8268          var d=pts[parseInt(this.dataset.idx)];
8269          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(''):'';
8270          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>':'';
8271          showTT(e,
8272            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
8273            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
8274            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
8275            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
8276          );
8277          this.setAttribute('r','8');
8278        }});
8279        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
8280        c.addEventListener('mousemove',moveTT);
8281        c.addEventListener('click',function(){{
8282          var d=pts[parseInt(this.dataset.idx)];
8283          if(d.html_url) window.open(d.html_url,'_blank');
8284        }});
8285      }});
8286
8287      renderTable(pts, yKey);
8288    }}
8289
8290    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
8291    var shProjFilter='', shBranchFilter='';
8292
8293    function fmtPST(isoStr){{
8294      if(!isoStr)return'';
8295      var d=new Date(isoStr);
8296      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
8297      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);}}
8298      function p(n){{return n<10?'0'+n:String(n);}}
8299      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++;}}}}
8300      var yr=d.getUTCFullYear();
8301      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
8302      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
8303      var isDST=d>=dstStart&&d<dstEnd;
8304      var off=isDST?-7*3600*1000:-8*3600*1000;
8305      var lbl=isDST?'PDT':'PST';
8306      var loc=new Date(d.getTime()+off);
8307      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
8308    }}
8309
8310    function getShRows(){{
8311      var proj=shProjFilter.toLowerCase().trim();
8312      var branch=shBranchFilter;
8313      return shData.filter(function(d){{
8314        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
8315        if(branch&&(d.branch||'')!==branch)return false;
8316        return true;
8317      }});
8318    }}
8319
8320    function renderShPage(){{
8321      var filtered=getShRows();
8322      if(shSortCol){{
8323        filtered.sort(function(a,b){{
8324          var va,vb;
8325          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
8326          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
8327          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
8328          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
8329          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
8330          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
8331        }});
8332      }}
8333      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
8334      shPage=Math.min(shPage,totalPages);
8335      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
8336      var visible=filtered.slice(start,end);
8337      var tbody=document.getElementById('sh-tbody');
8338      if(!tbody)return;
8339      tbody.innerHTML=visible.map(function(d){{
8340        var tsHtml=esc(fmtPST(d.timestamp));
8341        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>';
8342        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>';
8343        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
8344        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
8345        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
8346        var reportCell='';
8347        if(d.html_url){{
8348          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
8349          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>';}}
8350          reportCell+='</div>';
8351        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
8352        if(d.submodule_links&&d.submodule_links.length){{
8353          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
8354          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
8355          reportCell+='</div></details>';
8356        }}
8357        return '<tr>'
8358          +'<td>'+tsHtml+'</td>'
8359          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
8360          +'<td>'+runIdHtml+'</td>'
8361          +'<td>'+commitHtml+'</td>'
8362          +'<td>'+branchHtml+'</td>'
8363          +'<td>'+tags+'</td>'
8364          +'<td class="num">'+metricHtml+'</td>'
8365          +'<td class="report-cell">'+reportCell+'</td>'
8366          +'</tr>';
8367      }}).join('');
8368      var pgRange=document.getElementById('sh-pg-range');
8369      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
8370      var pgInfo=document.getElementById('sh-pg-info');
8371      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
8372      var pgBtns=document.getElementById('sh-pg-btns');
8373      if(pgBtns){{
8374        pgBtns.innerHTML='';
8375        function mkPgBtn(lbl,pg,active,disabled){{
8376          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
8377          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
8378          return b;
8379        }}
8380        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
8381        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
8382        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
8383        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
8384      }}
8385    }}
8386
8387    function wireTableBehavior(){{
8388      var pf=document.getElementById('sh-proj-filter');
8389      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
8390      var bf=document.getElementById('sh-branch-filter');
8391      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
8392      var rb=document.getElementById('sh-reset-btn');
8393      if(rb)rb.addEventListener('click',function(){{
8394        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
8395        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
8396        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
8397        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');}});
8398        renderShPage();
8399      }});
8400      var pps=document.getElementById('sh-per-page');
8401      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
8402      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
8403      ths.forEach(function(th){{
8404        th.addEventListener('click',function(e){{
8405          if(e.target.classList.contains('col-resize-handle'))return;
8406          var col=th.dataset.col;
8407          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
8408          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
8409          th.classList.add('sort-'+shSortOrder);
8410          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
8411          shPage=1;renderShPage();
8412        }});
8413      }});
8414      var table=document.getElementById('scan-history-table');
8415      if(!table)return;
8416      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
8417      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
8418      allThs.forEach(function(th,i){{
8419        var handle=th.querySelector('.col-resize-handle');
8420        if(!handle||!cols[i])return;
8421        var startX,startW;
8422        handle.addEventListener('mousedown',function(e){{
8423          e.stopPropagation();e.preventDefault();
8424          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
8425          handle.classList.add('dragging');
8426          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
8427          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
8428          document.addEventListener('mousemove',onMove);
8429          document.addEventListener('mouseup',onUp);
8430        }});
8431      }});
8432    }}
8433
8434    function renderTable(pts, yKey){{
8435      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
8436      var wrap=document.getElementById('data-table-wrap');
8437      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
8438      var yLabel=Y_LABELS[yKey]||yKey||'';
8439      shData=pts.slice().reverse();
8440      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
8441      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
8442      var branches={{}};
8443      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
8444      var branchOpts='<option value="">All branches</option>';
8445      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
8446      wrap.innerHTML=
8447        '<div class="chart-section-header">SCAN HISTORY</div>'+
8448        '<div class="filter-row">'+
8449          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
8450          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
8451          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
8452        '</div>'+
8453        '<div class="table-wrap">'+
8454        '<table id="scan-history-table" class="data-table">'+
8455        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
8456        '<thead><tr id="sh-thead">'+
8457        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8458        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8459        '<th>Run ID<div class="col-resize-handle"></div></th>'+
8460        '<th>Commit<div class="col-resize-handle"></div></th>'+
8461        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
8462        '<th>Tags<div class="col-resize-handle"></div></th>'+
8463        '<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>'+
8464        '<th>Report<div class="col-resize-handle"></div></th>'+
8465        '</tr></thead>'+
8466        '<tbody id="sh-tbody"></tbody>'+
8467        '</table>'+
8468        '</div>'+
8469        '<div class="pagination">'+
8470          '<span class="pagination-info" id="sh-pg-info"></span>'+
8471          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
8472          '<div style="display:flex;align-items:center;gap:8px;">'+
8473            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
8474            '<select class="filter-select" id="sh-per-page">'+
8475              '<option value="10">10 per page</option>'+
8476              '<option value="25" selected>25 per page</option>'+
8477              '<option value="50">50 per page</option>'+
8478              '<option value="100">100 per page</option>'+
8479            '</select>'+
8480            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
8481          '</div>'+
8482        '</div>';
8483      wireTableBehavior();
8484      renderShPage();
8485    }}
8486
8487    function exportXLSX(){{
8488      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
8489      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
8490      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
8491      var s1R=sorted.map(function(d){{
8492        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||''];
8493      }});
8494      var pm={{}};
8495      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
8496      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'];
8497      var s2R=Object.keys(pm).map(function(p){{
8498        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
8499        var lat=sc[sc.length-1],fst=sc[0];
8500        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
8501        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);
8502        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];
8503      }});
8504      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
8505      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
8506      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
8507      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
8508    }}
8509
8510    function buildXLSX(sheets,chartRows,chartRows2){{
8511      function s2b(s){{return new TextEncoder().encode(s);}}
8512      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
8513      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;}}
8514      function crc32(d){{
8515        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;}}}}
8516        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
8517      }}
8518      function buildSheet(hdr,rows,drawRid,withCtrl){{
8519        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
8520        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
8521        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
8522        x+='<row r="1">';
8523        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
8524        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>';}}
8525        x+='</row>';
8526        rows.forEach(function(row,ri){{
8527          var rn=ri+2;
8528          x+='<row r="'+rn+'">';
8529          row.forEach(function(cell,ci){{
8530            var addr=col2l(ci+1)+rn;
8531            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
8532            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
8533          }});
8534          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>';}}
8535          x+='</row>';
8536        }});
8537        x+='</sheetData>';
8538        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>';}}
8539        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
8540        return x+'</worksheet>';
8541      }}
8542      function buildChartXML(rows){{
8543        var sn="'Scan History'";
8544        var nr=rows.length,er=nr+1;
8545        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'}}];
8546        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8547        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">';
8548        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8549        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8550        sd.forEach(function(s,i){{
8551          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8552          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>';
8553          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8554          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>';
8555          var dlp=(i===2)?'b':'t';
8556          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>';
8557          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8558          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8559          x+='</c:strCache></c:strRef></c:cat>';
8560          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+'"/>';
8561          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8562          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8563        }});
8564        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
8565        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>';
8566        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>';
8567        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8568        return x;
8569      }}
8570      function buildChartXML2(rows){{
8571        var sn="'By Project'";
8572        var nr=rows.length,er=nr+1;
8573        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'}}];
8574        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8575        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">';
8576        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
8577        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8578        sd.forEach(function(s,i){{
8579          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
8580          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>';
8581          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
8582          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>';
8583          var dlp=(i===2)?'b':'t';
8584          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>';
8585          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8586          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8587          x+='</c:strCache></c:strRef></c:cat>';
8588          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+'"/>';
8589          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
8590          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8591        }});
8592        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
8593        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>';
8594        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>';
8595        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
8596        return x;
8597      }}
8598      function buildChartXML3(rows){{
8599        var sn="'Scan History'";
8600        var nr=rows.length,er=nr+1;
8601        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8602        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">';
8603        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
8604        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
8605        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
8606        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>';
8607        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
8608        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>';
8609        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>';
8610        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
8611        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
8612        x+='</c:strCache></c:strRef></c:cat>';
8613        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+'"/>';
8614        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
8615        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
8616        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
8617        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>';
8618        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>';
8619        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>';
8620        return x;
8621      }}
8622      var hasChart=!!(chartRows&&chartRows.length);
8623      var nr=hasChart?chartRows.length:0;
8624      var hasChart2=!!(chartRows2&&chartRows2.length);
8625      var nr2=hasChart2?chartRows2.length:0;
8626      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>';
8627      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"/>';
8628      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
8629      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"/>';}}
8630      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"/>';}}
8631      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
8632      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>';
8633      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
8634      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"/>';}});
8635      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
8636      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>';
8637      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
8638      wbx+='</sheets></workbook>';
8639      var files=[
8640        {{name:'[Content_Types].xml',data:s2b(ct)}},
8641        {{name:'_rels/.rels',data:s2b(dotrels)}},
8642        {{name:'xl/workbook.xml',data:s2b(wbx)}},
8643        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
8644        {{name:'xl/styles.xml',data:s2b(styl)}}
8645      ];
8646      // Chart embedded directly in Scan History (sheet1); By Project is plain
8647      sheets.forEach(function(s,i){{
8648        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)))}});
8649      }});
8650      if(hasChart){{
8651        var fromRow=nr+4,toRow=nr+24;
8652        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>')}});
8653        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8654        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">';
8655        drx+='<xdr:twoCellAnchor editAs="twoCell">';
8656        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>';
8657        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>';
8658        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8659        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8660        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8661        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8662        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
8663        var focRow=toRow+2,focRowEnd=toRow+22;
8664        drx+='<xdr:twoCellAnchor editAs="twoCell">';
8665        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>';
8666        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>';
8667        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8668        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8669        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8670        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
8671        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
8672        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
8673        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>')}});
8674        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
8675        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
8676      }}
8677      if(hasChart2){{
8678        var fromRow2=nr2+4,toRow2=nr2+24;
8679        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>')}});
8680        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8681        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">';
8682        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
8683        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>';
8684        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>';
8685        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
8686        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
8687        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
8688        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
8689        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
8690        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
8691        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>')}});
8692        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
8693      }}
8694      var parts=[],offsets=[],total=0;
8695      files.forEach(function(f){{
8696        offsets.push(total);
8697        var nb=s2b(f.name),crc=crc32(f.data);
8698        var h=new DataView(new ArrayBuffer(30+nb.length));
8699        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
8700        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
8701        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
8702        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
8703        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
8704        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
8705        total+=30+nb.length+f.data.length;
8706      }});
8707      var cdStart=total;
8708      files.forEach(function(f,fi){{
8709        var nb=s2b(f.name),crc=crc32(f.data);
8710        var cd=new DataView(new ArrayBuffer(46+nb.length));
8711        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
8712        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
8713        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
8714        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
8715        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
8716        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
8717        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
8718      }});
8719      var cdSz=total-cdStart;
8720      var eocd=new DataView(new ArrayBuffer(22));
8721      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
8722      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
8723      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
8724      parts.push(new Uint8Array(eocd.buffer));
8725      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
8726      var out=new Uint8Array(sz);var off=0;
8727      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
8728      return out.buffer;
8729    }}
8730
8731    function exportPNG(){{
8732      var svgEl=document.querySelector('#chart-wrap svg');
8733      if(!svgEl){{alert('No chart to export yet.');return;}}
8734      var svgStr=new XMLSerializer().serializeToString(svgEl);
8735      var vb=svgEl.viewBox.baseVal,scale=2;
8736      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
8737      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
8738      var url=URL.createObjectURL(blob);
8739      var img=new Image();
8740      img.onload=function(){{
8741        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
8742        var ctx=canvas.getContext('2d');
8743        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
8744        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
8745        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
8746        URL.revokeObjectURL(url);
8747        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
8748      }};
8749      img.src=url;
8750    }}
8751
8752    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
8753      var el=document.getElementById(id);
8754      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
8755    }});
8756    rootSel.addEventListener('change',function(){{
8757      populateSubmodules(rootSel.value);
8758      loadAndRender();
8759    }});
8760    if(subSel)subSel.addEventListener('change',loadAndRender);
8761
8762    var xlsxBtn=document.getElementById('export-xlsx-btn');
8763    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
8764    var pngBtn=document.getElementById('export-png-btn');
8765    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
8766
8767    // ── Clean-up modal ───────────────────────────────────────────────────────
8768    (function(){{
8769      var triggerBtn=document.getElementById('cleanup-runs-btn');
8770      if(!triggerBtn)return;
8771      var modal=document.createElement('div');
8772      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;';
8773      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);">'
8774        +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
8775        +'<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>'
8776        +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
8777        +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
8778        +'<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;">'
8779        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
8780        +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
8781        +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
8782        +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
8783        +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
8784        +'</div></div>';
8785      document.body.appendChild(modal);
8786      triggerBtn.addEventListener('click',function(){{
8787        document.getElementById('cleanup-status').style.display='none';
8788        modal.style.display='flex';
8789      }});
8790      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
8791      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8792      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
8793        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
8794        var confirmBtn=this;
8795        confirmBtn.disabled=true;
8796        var status=document.getElementById('cleanup-status');
8797        status.style.display='block';
8798        status.style.background='#dbeafe';status.style.color='#1e40af';
8799        status.textContent='Deleting\u2026';
8800        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
8801        .then(function(resp){{
8802          return resp.json().then(function(d){{
8803            if(resp.ok){{
8804              status.style.background='#dcfce7';status.style.color='#166534';
8805              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
8806              setTimeout(function(){{window.location.reload();}},1500);
8807            }}else{{
8808              status.style.background='#fee2e2';status.style.color='#991b1b';
8809              status.textContent='Error: '+(d.error||'Unexpected error');
8810              confirmBtn.disabled=false;
8811            }}
8812          }});
8813        }})
8814        .catch(function(e){{
8815          status.style.background='#fee2e2';status.style.color='#991b1b';
8816          status.textContent='Network error: '+String(e);
8817          confirmBtn.disabled=false;
8818        }});
8819      }});
8820    }})();
8821
8822    // ── Retention policy panel ────────────────────────────────────────────────
8823    (function(){{
8824      var triggerBtn=document.getElementById('retention-policy-btn');
8825      if(!triggerBtn)return;
8826      var modal=document.createElement('div');
8827      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;';
8828      modal.innerHTML=''
8829        +'<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);">'
8830        +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
8831        +'<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>'
8832        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
8833        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
8834        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
8835        +'</div>'
8836        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
8837        +'<div>'
8838        +'<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>'
8839        +'<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;">'
8840        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
8841        +'</div>'
8842        +'<div>'
8843        +'<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>'
8844        +'<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;">'
8845        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
8846        +'</div>'
8847        +'</div>'
8848        +'<div style="margin-bottom:20px;">'
8849        +'<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>'
8850        +'<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;">'
8851        +'<option value="1">Every hour</option>'
8852        +'<option value="6">Every 6 hours</option>'
8853        +'<option value="12">Every 12 hours</option>'
8854        +'<option value="24" selected>Every 24 hours</option>'
8855        +'<option value="48">Every 2 days</option>'
8856        +'<option value="72">Every 3 days</option>'
8857        +'<option value="168">Every week</option>'
8858        +'</select>'
8859        +'</div>'
8860        +'<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>'
8861        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
8862        +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
8863        +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
8864        +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
8865        +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
8866        +'</div>'
8867        +'</div>';
8868      document.body.appendChild(modal);
8869
8870      function rpShowStatus(msg,ok){{
8871        var s=document.getElementById('rp-status');
8872        s.style.display='block';
8873        s.style.background=ok?'#dcfce7':'#fee2e2';
8874        s.style.color=ok?'#166534':'#991b1b';
8875        s.textContent=msg;
8876      }}
8877      function fmtAgo(iso){{
8878        if(!iso)return'Never';
8879        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
8880        if(diff<60)return diff+'s ago';
8881        if(diff<3600)return Math.floor(diff/60)+'m ago';
8882        if(diff<86400)return Math.floor(diff/3600)+'h ago';
8883        return Math.floor(diff/86400)+'d ago';
8884      }}
8885      function loadPolicy(){{
8886        fetch('/api/cleanup-policy')
8887          .then(function(r){{return r.json();}})
8888          .then(function(d){{
8889            var p=d.policy;
8890            document.getElementById('rp-enabled').checked=p?p.enabled:false;
8891            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
8892            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
8893            var sel=document.getElementById('rp-interval');
8894            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;}}}}}}
8895            var lr=document.getElementById('rp-last-run');
8896            if(d.last_run_at){{
8897              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'):'');
8898            }}else{{
8899              lr.textContent='Auto-cleanup has not run yet.';
8900            }}
8901          }})
8902          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
8903      }}
8904
8905      triggerBtn.addEventListener('click',function(){{
8906        document.getElementById('rp-status').style.display='none';
8907        loadPolicy();
8908        modal.style.display='flex';
8909      }});
8910      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
8911      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
8912
8913      document.getElementById('rp-save-btn').addEventListener('click',function(){{
8914        var enabled=document.getElementById('rp-enabled').checked;
8915        var ageVal=document.getElementById('rp-max-age').value.trim();
8916        var countVal=document.getElementById('rp-max-count').value.trim();
8917        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
8918        if(enabled&&!ageVal&&!countVal){{
8919          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
8920          return;
8921        }}
8922        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
8923        var saveBtn=document.getElementById('rp-save-btn');
8924        saveBtn.disabled=true;
8925        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
8926          .then(function(r){{
8927            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
8928            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
8929          }})
8930          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
8931          .finally(function(){{saveBtn.disabled=false;}});
8932      }});
8933
8934      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
8935        var btn=this;
8936        btn.disabled=true;
8937        btn.textContent='Running\u2026';
8938        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
8939          .then(function(r){{return r.json();}})
8940          .then(function(d){{
8941            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
8942            loadPolicy();
8943          }})
8944          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
8945          .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
8946      }});
8947    }})();
8948
8949    populateSubmodules(rootSel.value);
8950    loadAndRender();
8951
8952    (function randomizeWatermarks() {{
8953      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
8954      if (!wms.length) return;
8955      var placed = [];
8956      function tooClose(top, left) {{
8957        for (var i = 0; i < placed.length; i++) {{
8958          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
8959          if (dt < 16 && dl < 12) return true;
8960        }}
8961        return false;
8962      }}
8963      function pick(leftBand) {{
8964        for (var attempt = 0; attempt < 50; attempt++) {{
8965          var top = Math.random() * 88 + 2;
8966          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8967          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
8968        }}
8969        var top = Math.random() * 88 + 2;
8970        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
8971        placed.push([top, left]); return [top, left];
8972      }}
8973      var half = Math.floor(wms.length / 2);
8974      wms.forEach(function (img, i) {{
8975        var pos = pick(i < half);
8976        var size = Math.floor(Math.random() * 100 + 120);
8977        var rot = (Math.random() * 360).toFixed(1);
8978        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
8979        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;
8980      }});
8981    }})();
8982    (function spawnCodeParticles() {{
8983      var container = document.getElementById('code-particles');
8984      if (!container) return;
8985      var snippets = [
8986        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
8987        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
8988        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
8989        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
8990        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
8991      ];
8992      var count = 38;
8993      for (var i = 0; i < count; i++) {{
8994        (function(idx) {{
8995          var el = document.createElement('span');
8996          el.className = 'code-particle';
8997          el.textContent = snippets[idx % snippets.length];
8998          var left = Math.random() * 94 + 2;
8999          var top = Math.random() * 88 + 6;
9000          var dur = (Math.random() * 10 + 9).toFixed(1);
9001          var delay = (Math.random() * 18).toFixed(1);
9002          var rot = (Math.random() * 26 - 13).toFixed(1);
9003          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
9004          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
9005          container.appendChild(el);
9006        }})(i);
9007      }}
9008    }})();
9009  </script>
9010  <footer class="site-footer">
9011    local code analysis - metrics, history and reports
9012    &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>
9013    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9014    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9015    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9016    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9017  </footer>
9018  <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>
9019</body>
9020</html>"##,
9021    );
9022
9023    Html(html).into_response()
9024}
9025
9026fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9027    use std::collections::HashMap;
9028    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
9029        return vec![];
9030    }
9031    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9032    for rec in per_file_records {
9033        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9034            let e = totals.entry(lang.display_name().to_string()).or_default();
9035            e.0 += u64::from(cov.lines_found);
9036            e.1 += u64::from(cov.lines_hit);
9037        }
9038    }
9039    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
9040    let mut pairs: Vec<(String, f64)> = totals
9041        .into_iter()
9042        .filter(|(_, (found, _))| *found > 0)
9043        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9044        .collect();
9045    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9046    pairs
9047        .iter()
9048        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
9049        .collect()
9050}
9051
9052fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
9053    let mut high = 0u64;
9054    let mut mid = 0u64;
9055    let mut low = 0u64;
9056    for rec in per_file_records {
9057        if let Some(cov) = &rec.coverage {
9058            if cov.lines_found == 0 {
9059                continue;
9060            }
9061            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
9062            if pct >= 80.0 {
9063                high += 1;
9064            } else if pct >= 50.0 {
9065                mid += 1;
9066            } else {
9067                low += 1;
9068            }
9069        }
9070    }
9071    (high, mid, low)
9072}
9073
9074fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
9075    let mut arr: Vec<serde_json::Value> = per_file_records
9076        .iter()
9077        .filter_map(|rec| {
9078            rec.coverage.as_ref().map(|cov| {
9079                let line_pct = if cov.lines_found > 0 {
9080                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
9081                        / 10.0
9082                } else {
9083                    0.0
9084                };
9085                let fn_pct = if cov.functions_found > 0 {
9086                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
9087                        .round()
9088                        / 10.0
9089                } else {
9090                    -1.0
9091                };
9092                serde_json::json!({
9093                    "rel": rec.relative_path,
9094                    "lang": rec.language.map_or("?", |l| l.display_name()),
9095                    "line_pct": line_pct,
9096                    "fn_pct": fn_pct,
9097                    "lhit": cov.lines_hit,
9098                    "lfound": cov.lines_found,
9099                    "fhit": cov.functions_hit,
9100                    "ffound": cov.functions_found,
9101                })
9102            })
9103        })
9104        .collect();
9105    arr.sort_by(|a, b| {
9106        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
9107        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
9108        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
9109    });
9110    arr
9111}
9112
9113#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9114fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
9115    let mut langs: Vec<&sloc_core::LanguageSummary> = run
9116        .totals_by_language
9117        .iter()
9118        .filter(|l| l.test_count > 0)
9119        .collect();
9120    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9121    let lang_tests: Vec<serde_json::Value> = langs
9122        .iter()
9123        .map(|l| {
9124            let d = if l.code_lines > 0 {
9125                l.test_count as f64 / l.code_lines as f64 * 1000.0
9126            } else {
9127                0.0
9128            };
9129            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9130                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9131                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9132        })
9133        .collect();
9134    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
9135    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9136    let t = &run.summary_totals;
9137    let total_tests = t.test_count;
9138    let density = if t.code_lines > 0 {
9139        total_tests as f64 / t.code_lines as f64 * 1000.0
9140    } else {
9141        0.0
9142    };
9143    let most_tested = langs.first().map_or_else(
9144        || "\u{2014}".to_string(),
9145        |l| l.language.display_name().to_string(),
9146    );
9147    let test_files: u64 = run
9148        .per_file_records
9149        .iter()
9150        .filter(|f| f.raw_line_categories.test_count > 0)
9151        .count() as u64;
9152    let cov_line = if t.coverage_lines_found > 0 {
9153        format!(
9154            "{:.1}",
9155            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
9156        )
9157    } else {
9158        "0".to_string()
9159    };
9160    let cov_fn = if t.coverage_functions_found > 0 {
9161        format!(
9162            "{:.1}",
9163            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
9164        )
9165    } else {
9166        "0".to_string()
9167    };
9168    let cov_branch = if t.coverage_branches_found > 0 {
9169        format!(
9170            "{:.1}",
9171            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
9172        )
9173    } else {
9174        "0".to_string()
9175    };
9176    let has_cov = !cov_arr.is_empty();
9177    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
9178    serde_json::json!({
9179        "totals": {
9180            "test_count": total_tests,
9181            "assertions": t.test_assertion_count,
9182            "suites": t.test_suite_count,
9183            "test_files": test_files,
9184            "total_files": t.files_analyzed,
9185            "density_str": format!("{density:.1}"),
9186            "most_tested": most_tested,
9187            "langs_with_tests": langs.len(),
9188            "cov_line": cov_line,
9189            "cov_fn": cov_fn,
9190            "cov_branch": cov_branch,
9191        },
9192        "lang_tests": lang_tests,
9193        "cov": cov_arr,
9194        "cov_tiers": {"high": high, "mid": mid, "low": low},
9195        "file_cov": file_cov_arr,
9196        "has_coverage": has_cov,
9197        "submodules": {},
9198    })
9199}
9200
9201#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9202fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
9203    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
9204        .language_summaries
9205        .iter()
9206        .filter(|l| l.test_count > 0)
9207        .collect();
9208    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
9209    let lang_tests: Vec<serde_json::Value> = langs
9210        .iter()
9211        .map(|l| {
9212            let d = if l.code_lines > 0 {
9213                l.test_count as f64 / l.code_lines as f64 * 1000.0
9214            } else {
9215                0.0
9216            };
9217            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
9218                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
9219                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
9220        })
9221        .collect();
9222    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
9223    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
9224    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
9225    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
9226    let density = if sub.code_lines > 0 {
9227        total_tests as f64 / sub.code_lines as f64 * 1000.0
9228    } else {
9229        0.0
9230    };
9231    let most_tested = langs.first().map_or_else(
9232        || "\u{2014}".to_string(),
9233        |l| l.language.display_name().to_string(),
9234    );
9235    serde_json::json!({
9236        "totals": {
9237            "test_count": total_tests,
9238            "assertions": total_assertions,
9239            "suites": total_suites,
9240            "test_files": test_files_approx,
9241            "total_files": sub.files_analyzed,
9242            "density_str": format!("{density:.1}"),
9243            "most_tested": most_tested,
9244            "langs_with_tests": langs.len(),
9245            "cov_line": "0",
9246            "cov_fn": "0",
9247            "cov_branch": "0",
9248        },
9249        "lang_tests": lang_tests,
9250        "cov": [],
9251        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
9252        "has_coverage": false,
9253    })
9254}
9255
9256fn compute_cov_json_str(run: &AnalysisRun) -> String {
9257    use std::collections::HashMap;
9258    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
9259    for rec in &run.per_file_records {
9260        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
9261            let e = totals.entry(lang.display_name().to_string()).or_default();
9262            e.0 += u64::from(cov.lines_found);
9263            e.1 += u64::from(cov.lines_hit);
9264        }
9265    }
9266    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
9267    let mut pairs: Vec<(String, f64)> = totals
9268        .into_iter()
9269        .filter(|(_, (found, _))| *found > 0)
9270        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
9271        .collect();
9272    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
9273    let parts: Vec<String> = pairs
9274        .iter()
9275        .map(|(lang, pct)| {
9276            let name = lang.replace('"', "\\\"");
9277            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
9278        })
9279        .collect();
9280    format!("[{}]", parts.join(","))
9281}
9282
9283fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
9284    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
9285    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
9286}
9287
9288fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
9289    let mut entry = build_test_scope_entry(run);
9290    if !run.submodule_summaries.is_empty() {
9291        let subs: serde_json::Map<String, serde_json::Value> = run
9292            .submodule_summaries
9293            .iter()
9294            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
9295            .collect();
9296        entry["submodules"] = serde_json::Value::Object(subs);
9297    }
9298    entry
9299}
9300
9301fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
9302    let name = l.language.display_name().replace('"', "\\\"");
9303    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
9304    let density = if l.code_lines > 0 {
9305        l.test_count as f64 / l.code_lines as f64 * 1000.0
9306    } else {
9307        0.0
9308    };
9309    format!(
9310        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
9311        name = name,
9312        t = l.test_count,
9313        a = l.test_assertion_count,
9314        s = l.test_suite_count,
9315        c = l.code_lines,
9316        d = density,
9317        f = l.files,
9318    )
9319}
9320
9321fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
9322    let Some(r) = run else {
9323        return "[]".to_string();
9324    };
9325    let mut langs: Vec<&sloc_core::LanguageSummary> = r
9326        .totals_by_language
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 parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
9332    format!("[{}]", parts.join(","))
9333}
9334
9335/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
9336async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
9337    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
9338    scope_map.insert(
9339        "__all__".to_string(),
9340        latest_run.map_or_else(
9341            || {
9342                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
9343                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
9344                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
9345                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
9346                    "has_coverage":false,"submodules":{}})
9347            },
9348            build_test_scope_entry,
9349        ),
9350    );
9351    let all_roots: Vec<String> = {
9352        let reg = state.registry.lock().await;
9353        let mut seen = std::collections::BTreeSet::new();
9354        reg.entries
9355            .iter()
9356            .flat_map(|e| e.input_roots.iter().cloned())
9357            .filter(|r| seen.insert(r.clone()))
9358            .collect()
9359    };
9360    for root in &all_roots {
9361        let json_path = {
9362            let reg = state.registry.lock().await;
9363            reg.entries
9364                .iter()
9365                .find(|e| e.input_roots.iter().any(|r| r == root))
9366                .and_then(|e| e.json_path.clone())
9367        };
9368        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
9369            let json_str = tokio::fs::read_to_string(&p).await.ok();
9370            json_str
9371                .as_deref()
9372                .and_then(|s| serde_json::from_str(s).ok())
9373        } else {
9374            None
9375        };
9376        if let Some(ref run) = run_for_root {
9377            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
9378        }
9379    }
9380    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
9381}
9382
9383// GET /test-metrics
9384#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
9385#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
9386async fn test_metrics_handler(
9387    State(state): State<AppState>,
9388    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9389) -> Response {
9390    auto_scan_watched_dirs(&state).await;
9391    let watched_dirs_list: Vec<String> = {
9392        let wd = state.watched_dirs.lock().await;
9393        wd.dirs.iter().map(|p| p.display().to_string()).collect()
9394    };
9395    let latest_run: Option<AnalysisRun> = {
9396        let json_path = {
9397            let reg = state.registry.lock().await;
9398            reg.entries.first().and_then(|e| e.json_path.clone())
9399        };
9400        if let Some(p) = json_path {
9401            let json_str = tokio::fs::read_to_string(&p).await.ok();
9402            json_str
9403                .as_deref()
9404                .and_then(|s| serde_json::from_str(s).ok())
9405        } else {
9406            None
9407        }
9408    };
9409
9410    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
9411    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
9412
9413    // Build coverage chart JSON (per-language avg line coverage %).
9414    let cov_json: String = latest_run
9415        .as_ref()
9416        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9417        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
9418
9419    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
9420    let _cov_tier_json: String = latest_run
9421        .as_ref()
9422        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
9423        .map_or_else(
9424            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
9425            compute_cov_tier_json_str,
9426        );
9427
9428    let total_tests: u64 = latest_run
9429        .as_ref()
9430        .map_or(0, |r| r.summary_totals.test_count);
9431    let total_assertions: u64 = latest_run
9432        .as_ref()
9433        .map_or(0, |r| r.summary_totals.test_assertion_count);
9434    let total_suites: u64 = latest_run
9435        .as_ref()
9436        .map_or(0, |r| r.summary_totals.test_suite_count);
9437    let total_code: u64 = latest_run
9438        .as_ref()
9439        .map_or(0, |r| r.summary_totals.code_lines);
9440    let workspace_density: f64 = if total_code > 0 {
9441        total_tests as f64 / total_code as f64 * 1000.0
9442    } else {
9443        0.0
9444    };
9445    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
9446        r.totals_by_language
9447            .iter()
9448            .filter(|l| l.test_count > 0)
9449            .count()
9450    });
9451    let most_tested: String = latest_run
9452        .as_ref()
9453        .and_then(|r| {
9454            r.totals_by_language
9455                .iter()
9456                .filter(|l| l.test_count > 0)
9457                .max_by_key(|l| l.test_count)
9458        })
9459        .map_or_else(
9460            || "\u{2014}".to_string(),
9461            |l| l.language.display_name().to_string(),
9462        );
9463    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
9464        r.per_file_records
9465            .iter()
9466            .filter(|f| f.raw_line_categories.test_count > 0)
9467            .count() as u64
9468    });
9469    let total_files_analyzed: u64 = latest_run
9470        .as_ref()
9471        .map_or(0, |r| r.summary_totals.files_analyzed);
9472    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
9473
9474    // Aggregated coverage percentages from summary_totals
9475    let cov_line_pct_str: String = latest_run
9476        .as_ref()
9477        .filter(|r| r.summary_totals.coverage_lines_found > 0)
9478        .map_or_else(
9479            || "0".to_string(),
9480            |r| {
9481                format!(
9482                    "{:.1}",
9483                    r.summary_totals.coverage_lines_hit as f64
9484                        / r.summary_totals.coverage_lines_found as f64
9485                        * 100.0
9486                )
9487            },
9488        );
9489    let cov_fn_pct_str: String = latest_run
9490        .as_ref()
9491        .filter(|r| r.summary_totals.coverage_functions_found > 0)
9492        .map_or_else(
9493            || "0".to_string(),
9494            |r| {
9495                format!(
9496                    "{:.1}",
9497                    r.summary_totals.coverage_functions_hit as f64
9498                        / r.summary_totals.coverage_functions_found as f64
9499                        * 100.0
9500                )
9501            },
9502        );
9503    let cov_branch_pct_str: String = latest_run
9504        .as_ref()
9505        .filter(|r| r.summary_totals.coverage_branches_found > 0)
9506        .map_or_else(
9507            || "0".to_string(),
9508            |r| {
9509                format!(
9510                    "{:.1}",
9511                    r.summary_totals.coverage_branches_hit as f64
9512                        / r.summary_totals.coverage_branches_found as f64
9513                        * 100.0
9514                )
9515            },
9516        );
9517
9518    let cov_no_data_notice = if has_coverage {
9519        String::new()
9520    } else {
9521        String::from(
9522            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
9523<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>
9524<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
9525  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
9526  <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>
9527  <span style="color:var(--muted);font-size:12px;">&middot;</span>
9528  <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>
9529  <span style="color:var(--muted);font-size:12px;">&middot;</span>
9530  <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>
9531</div>
9532<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
9533</div>"#,
9534        )
9535    };
9536
9537    let workspace_density_str = format!("{workspace_density:.1}");
9538    let nonce = &csp_nonce;
9539    let version = env!("CARGO_PKG_VERSION");
9540
9541    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
9542    // of interactive controls — folder watching is managed by the host administrator.
9543    let watched_dirs_html: String = if state.server_mode {
9544        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()
9545    } else {
9546        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
9547            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
9548                .to_string()
9549        } else {
9550            watched_dirs_list
9551                .iter()
9552                .fold(String::new(), |mut s, d| {
9553                    use std::fmt::Write as _;
9554                    let escaped =
9555                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
9556                    write!(
9557                        s,
9558                        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>"#
9559                    ).expect("write to String is infallible");
9560                    s
9561                })
9562        };
9563        format!(
9564            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>"#
9565        )
9566    };
9567
9568    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
9569    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
9570
9571    let html = format!(
9572        r#"<!doctype html>
9573<html lang="en">
9574<head>
9575  <meta charset="utf-8" />
9576  <meta name="viewport" content="width=device-width, initial-scale=1" />
9577  <title>OxideSLOC | Test Metrics</title>
9578  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9579  <style nonce="{nonce}">
9580    :root {{
9581      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
9582      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
9583      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
9584      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
9585      --info-bg:#eef3ff; --info-text:#4467d8;
9586    }}
9587    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
9588    *{{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;}}
9589    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9590    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
9591    .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;}}
9592    @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));}}}}
9593    .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);}}
9594    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
9595    .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));}}
9596    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9597    .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;}}
9598    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
9599    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
9600    @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; }} }}
9601    .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;}}
9602    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9603    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9604    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9605    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
9606    .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;}}
9607    .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;}}
9608    .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;}}
9609    .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;}}
9610    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9611    .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);}}
9612    .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;}}
9613    .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;}}
9614    .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;}}
9615    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9616    .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;}}
9617    .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);}}
9618    .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;}}
9619    .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;}}
9620    .tz-select:focus{{border-color:var(--oxide);}}
9621    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9622    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
9623    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
9624    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
9625    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
9626    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
9627    .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;}}
9628    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
9629    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
9630    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
9631    .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;}}
9632    .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;}}
9633    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
9634    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
9635    .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);}}
9636    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
9637    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
9638    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
9639    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9640    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
9641    .chart-canvas-wrap{{position:relative;height:280px;}}
9642    .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;}}
9643    .chart-no-data svg{{opacity:0.35;}}
9644    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
9645    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
9646    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9647    .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;}}
9648    .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;}}
9649    .data-table tr:last-child td{{border-bottom:none;}}
9650    .data-table tbody tr:hover td{{background:var(--surface-2);}}
9651    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
9652    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
9653    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
9654    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
9655    .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;}}
9656    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
9657    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
9658    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
9659    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
9660    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
9661    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
9662    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
9663    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
9664    .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;}}
9665    .chart-select:focus{{border-color:var(--accent);}}
9666    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
9667    .trend-canvas-wrap{{position:relative;height:260px;}}
9668    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9669    .site-footer a{{color:var(--muted);}}
9670    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
9671    .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;}}
9672    .btn:hover{{background:var(--surface-2);}}
9673    .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;}}
9674    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9675    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
9676    .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;}}
9677    .scope-sel:focus{{border-color:var(--accent);}}
9678    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
9679    .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;}}
9680    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
9681    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
9682    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
9683    .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;}}
9684    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
9685    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
9686    .watched-chip-rm:hover{{color:var(--oxide);}}
9687    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
9688    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
9689    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
9690    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
9691    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
9692    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
9693    .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;}}
9694    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
9695    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
9696    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
9697    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
9698    .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;}}
9699    .cov-file-search:focus{{border-color:var(--accent);}}
9700    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
9701    .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;}}
9702    body.dark-theme .cov-file-search{{background:var(--surface);}}
9703    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9704    .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;}}
9705    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9706    .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;}}
9707    .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);}}
9708    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
9709    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
9710    .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;}}
9711    .chart-modal-close:hover{{opacity:.7;}}
9712    body.dark-theme .chart-modal{{background:var(--surface);}}
9713  </style>
9714</head>
9715<body>
9716  <div class="background-watermarks" aria-hidden="true">
9717    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9718    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9719    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9720    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9721    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9722    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
9723  </div>
9724  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9725  <div class="top-nav">
9726    <div class="top-nav-inner">
9727      <a class="brand" href="/">
9728        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9729        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
9730      </a>
9731      <div class="nav-right">
9732        <a class="nav-pill" href="/">Home</a>
9733        <div class="nav-dropdown">
9734          <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>
9735          <div class="nav-dropdown-menu">
9736            <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>
9737          </div>
9738        </div>
9739        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9740        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
9741        <div class="nav-dropdown">
9742          <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>
9743          <div class="nav-dropdown-menu">
9744            <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>
9745          </div>
9746        </div>
9747        <div class="server-status-wrap" id="server-status-wrap">
9748          <div class="nav-pill server-online-pill" id="server-status-pill">
9749            <span class="status-dot" id="status-dot"></span>
9750            <span id="server-status-label">Server</span>
9751            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9752          </div>
9753          <div class="server-status-tip">
9754            OxideSLOC is running — accessible on your network.
9755            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9756          </div>
9757        </div>
9758        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9759          <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>
9760        </button>
9761        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9762          <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>
9763          <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>
9764        </button>
9765      </div>
9766    </div>
9767  </div>
9768
9769  <div class="page">
9770    {watched_dirs_html}
9771    <div class="scope-bar">
9772      <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>
9773      <span class="scope-label">Scope</span>
9774      <div class="scope-sel-wrap">
9775        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
9776        <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);">
9777          <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>
9778          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
9779        </div>
9780      </div>
9781    </div>
9782    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9783      <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>
9784      <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>
9785      <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>
9786      <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>
9787    </div>
9788    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
9789      <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>
9790      <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>
9791      <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>
9792      <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>
9793    </div>
9794
9795    <div class="panel" id="viz-panel">
9796      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
9797
9798      <div class="chart-box" style="margin-bottom:18px;">
9799        <div class="chart-box-header">
9800          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
9801          <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9802        </div>
9803        <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope.</p>
9804        <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
9805        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
9806      </div>
9807
9808      <div class="chart-row">
9809        <div class="chart-box">
9810          <div class="chart-box-header">
9811            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
9812            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9813          </div>
9814          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
9815          <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>
9816        </div>
9817        <div class="chart-box">
9818          <div class="chart-box-header">
9819            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
9820            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9821          </div>
9822          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
9823          <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>
9824        </div>
9825      </div>
9826
9827      <div class="chart-row">
9828        <div class="chart-box">
9829          <div class="chart-box-header">
9830            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
9831            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9832          </div>
9833          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
9834          <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>
9835        </div>
9836        <div class="chart-box" id="suites-chart-box">
9837          <div class="chart-box-header">
9838            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
9839            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
9840          </div>
9841          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
9842          <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>
9843        </div>
9844      </div>
9845
9846      <div class="chart-row">
9847        <div class="chart-box">
9848          <div class="chart-box-title">Test Files Breakdown</div>
9849          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
9850          <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>
9851        </div>
9852        <div class="chart-box">
9853          <div class="chart-box-title">Test Composition</div>
9854          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
9855          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
9856          <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>
9857        </div>
9858      </div>
9859    </div>
9860
9861    <div class="panel">
9862      <h1>Test Metrics</h1>
9863      <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>
9864
9865      <div class="section-header">Language Breakdown</div>
9866      {cov_no_data_notice}
9867      <div style="overflow-x:auto;">
9868        <table class="data-table" id="lang-table">
9869          <thead><tr>
9870            <th>Language</th>
9871            <th class="num">Test Fns</th>
9872            <th class="num">Assertions</th>
9873            <th class="num">Suites</th>
9874            <th class="num">Code Lines</th>
9875            <th class="num">Files</th>
9876            <th class="num">Density / 1K</th>
9877            <th>Relative Density</th>
9878          </tr></thead>
9879          <tbody id="lang-tbody"></tbody>
9880        </table>
9881      </div>
9882    </div>
9883
9884    <div class="panel" id="cov-panel" style="display:none;">
9885      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
9886      <div class="cov-gauge-row" id="cov-gauges">
9887        <div class="cov-gauge-card">
9888          <div class="cov-gauge-label">Line Coverage</div>
9889          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
9890          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
9891          <div class="cov-gauge-sub">Lines hit / instrumented</div>
9892        </div>
9893        <div class="cov-gauge-card">
9894          <div class="cov-gauge-label">Function Coverage</div>
9895          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
9896          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
9897          <div class="cov-gauge-sub">Functions hit / found</div>
9898        </div>
9899        <div class="cov-gauge-card">
9900          <div class="cov-gauge-label">Branch Coverage</div>
9901          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
9902          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
9903          <div class="cov-gauge-sub">Branches hit / found</div>
9904        </div>
9905      </div>
9906      <div class="chart-row">
9907        <div class="chart-box">
9908          <div class="chart-box-title">Line Coverage % by Language</div>
9909          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
9910        </div>
9911        <div class="chart-box">
9912          <div class="chart-box-title">Coverage Tier Distribution</div>
9913          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
9914        </div>
9915      </div>
9916
9917      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
9918      <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>
9919      <div class="cov-file-toolbar">
9920        <div class="cov-filter-tabs" id="cov-filter-tabs">
9921          <button class="cov-tab active" data-tier="all">All</button>
9922          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
9923          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
9924          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
9925          <button class="cov-tab" data-tier="high">High (≥80%)</button>
9926        </div>
9927        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
9928      </div>
9929      <div style="overflow-x:auto;">
9930        <table class="data-table" id="cov-file-table">
9931          <thead><tr>
9932            <th>File</th>
9933            <th>Lang</th>
9934            <th class="num">Line %</th>
9935            <th class="num">Lines Hit / Found</th>
9936            <th class="num">Fn %</th>
9937            <th class="num">Fns Hit / Found</th>
9938          </tr></thead>
9939          <tbody id="cov-file-tbody"></tbody>
9940        </table>
9941      </div>
9942      <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>
9943      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
9944    </div>
9945
9946  </div>
9947
9948  <footer class="site-footer">
9949    local code analysis - metrics, history and reports
9950    &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>
9951    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9952    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9953    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9954    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
9955  </footer>
9956
9957  <script nonce="{nonce}">
9958  (function() {{
9959    // Theme
9960    var b = document.body;
9961    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
9962    var tgl = document.getElementById('theme-toggle');
9963    if (tgl) tgl.addEventListener('click', function() {{
9964      var d = b.classList.toggle('dark-theme');
9965      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
9966    }});
9967
9968    // Watermarks
9969    (function() {{
9970      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9971      if (!wms.length) return;
9972      var placed = [];
9973      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;}}
9974      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];}}
9975      var half=Math.floor(wms.length/2);
9976      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;}});
9977    }})();
9978
9979    // Code particles
9980    (function() {{
9981      var container = document.getElementById('code-particles');
9982      if (!container) return;
9983      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
9984      for (var i = 0; i < 36; i++) {{
9985        (function(idx) {{
9986          var el = document.createElement('span');
9987          el.className = 'code-particle';
9988          el.textContent = snippets[idx % snippets.length];
9989          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
9990          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
9991          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
9992          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';
9993          container.appendChild(el);
9994        }})(i);
9995      }}
9996    }})();
9997
9998    // Settings modal
9999    (function() {{
10000      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'}}];
10001      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);}});}}
10002      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10003      var btn=document.getElementById('settings-btn');if(!btn)return;
10004      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10005      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>';
10006      document.body.appendChild(m);
10007      var g=document.getElementById('scheme-grid');
10008      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);}});
10009      var cl=document.getElementById('settings-close');
10010      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');}});
10011      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10012      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10013    }})();
10014
10015    // Watched folder picker
10016    (function() {{
10017      var btn = document.getElementById('add-watched-btn');
10018      if (!btn) return;
10019      btn.addEventListener('click', function() {{
10020        fetch('/pick-directory?kind=reports')
10021          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10022          .then(function(data) {{
10023            if (!data.cancelled && data.selected_path) {{
10024              var form = document.createElement('form');
10025              form.method = 'POST';
10026              form.action = '/watched-dirs/add';
10027              var ri = document.createElement('input');
10028              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10029              var fi = document.createElement('input');
10030              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10031              form.appendChild(ri); form.appendChild(fi);
10032              document.body.appendChild(form);
10033              form.submit();
10034            }}
10035          }})
10036          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10037      }});
10038    }})();
10039  }})();
10040  </script>
10041
10042  <script src="/static/chart.js" nonce="{nonce}"></script>
10043  <script nonce="{nonce}">
10044  (function() {{
10045    var SCOPE_DATA = {scope_data_json};
10046    var currentRoot = '__all__';
10047    var currentSub  = '';
10048    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
10049    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
10050    var ALL_CHARTS = [];
10051    var currentLangTests = [];
10052    var currentTrendPts = [];
10053
10054    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();}}
10055    function fmtFull(n){{return Number(n).toLocaleString();}}
10056    function isDark(){{return document.body.classList.contains('dark-theme');}}
10057    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
10058    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
10059    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
10060
10061    function makeDlPlugin(fmtFn, anchor) {{
10062      return {{
10063        afterDatasetsDraw: function(chart) {{
10064          var ctx = chart.ctx;
10065          var tc = txtClr();
10066          chart.data.datasets.forEach(function(ds, di) {{
10067            var meta = chart.getDatasetMeta(di);
10068            meta.data.forEach(function(el, idx) {{
10069              var label = fmtFn(ds.data[idx], di, idx);
10070              if (label == null || label === '') return;
10071              ctx.save();
10072              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
10073              ctx.fillStyle = tc;
10074              if (anchor === 'top') {{
10075                ctx.textAlign = 'center';
10076                ctx.textBaseline = 'bottom';
10077                ctx.fillText(String(label), el.x, el.y - 5);
10078              }} else {{
10079                ctx.textAlign = 'left';
10080                ctx.textBaseline = 'middle';
10081                ctx.fillText(String(label), el.x + 5, el.y);
10082              }}
10083              ctx.restore();
10084            }});
10085          }});
10086        }}
10087      }};
10088    }}
10089
10090    function makeTmOverlay(title, subtitle, h) {{
10091      var overlay = document.createElement('div');
10092      overlay.className = 'chart-modal-overlay';
10093      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
10094      var ch = Math.min(h || 560, maxH);
10095      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
10096      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>';
10097      document.body.appendChild(overlay);
10098      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
10099      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
10100      return document.getElementById('tm-modal-canvas');
10101    }}
10102
10103    function getDataset() {{
10104      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10105      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
10106      return r;
10107    }}
10108    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
10109
10110    function showNoData(id, show) {{
10111      var el = document.getElementById(id);
10112      if (!el) return;
10113      var wrap = el.previousElementSibling;
10114      el.style.display = show ? '' : 'none';
10115      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
10116    }}
10117
10118    function renderTestCharts(D) {{
10119      currentLangTests = D || [];
10120      testsChart = destroyChart(testsChart);
10121      densityChart = destroyChart(densityChart);
10122      if (!D || !D.length) {{
10123        showNoData('no-data-tests', true);
10124        showNoData('no-data-density', true);
10125        return;
10126      }}
10127      showNoData('no-data-tests', false);
10128      showNoData('no-data-density', false);
10129      var top15 = D.slice(0, 15);
10130      var canvas1 = document.getElementById('canvas-tests');
10131      if (canvas1) {{
10132        testsChart = new Chart(canvas1, {{
10133          type: 'bar',
10134          data: {{
10135            labels: top15.map(function(d){{ return d.lang; }}),
10136            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10137          }},
10138          options: {{
10139            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10140            layout: {{ padding: {{ right: 64 }} }},
10141            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10142            scales: {{
10143              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10144              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10145            }}
10146          }},
10147          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10148        }});
10149        ALL_CHARTS.push(testsChart);
10150      }}
10151      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
10152      var canvas2 = document.getElementById('canvas-density');
10153      if (canvas2) {{
10154        densityChart = new Chart(canvas2, {{
10155          type: 'bar',
10156          data: {{
10157            labels: topD.map(function(d){{ return d.lang; }}),
10158            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 }}]
10159          }},
10160          options: {{
10161            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10162            layout: {{ padding: {{ right: 64 }} }},
10163            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10164            scales: {{
10165              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10166              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10167            }}
10168          }},
10169          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10170        }});
10171        ALL_CHARTS.push(densityChart);
10172      }}
10173    }}
10174
10175    function renderAssertionsChart(D) {{
10176      assertionsChart = destroyChart(assertionsChart);
10177      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
10178      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10179      var canvas = document.getElementById('canvas-assertions');
10180      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
10181      showNoData('no-data-assertions', false);
10182      assertionsChart = new Chart(canvas, {{
10183        type: 'bar',
10184        data: {{
10185          labels: top15.map(function(d){{ return d.lang; }}),
10186          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10187        }},
10188        options: {{
10189          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10190          layout: {{ padding: {{ right: 64 }} }},
10191          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10192          scales: {{
10193            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10194            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10195          }}
10196        }},
10197        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10198      }});
10199      ALL_CHARTS.push(assertionsChart);
10200    }}
10201
10202    function renderSuitesChart(D) {{
10203      suitesChart = destroyChart(suitesChart);
10204      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
10205      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10206      var canvas = document.getElementById('canvas-suites');
10207      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
10208      showNoData('no-data-suites', false);
10209      suitesChart = new Chart(canvas, {{
10210        type: 'bar',
10211        data: {{
10212          labels: top15.map(function(d){{ return d.lang; }}),
10213          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 }}]
10214        }},
10215        options: {{
10216          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10217          layout: {{ padding: {{ right: 64 }} }},
10218          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10219          scales: {{
10220            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
10221            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10222          }}
10223        }},
10224        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10225      }});
10226      ALL_CHARTS.push(suitesChart);
10227    }}
10228
10229    function renderFilesChart(totals) {{
10230      filesChart = destroyChart(filesChart);
10231      var canvas = document.getElementById('canvas-files');
10232      if (!canvas) return;
10233      var testF = totals.test_files || 0;
10234      var totalF = totals.total_files || 0;
10235      var nonTest = Math.max(0, totalF - testF);
10236      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
10237      showNoData('no-data-files', false);
10238      var dark = isDark();
10239      filesChart = new Chart(canvas, {{
10240        type: 'doughnut',
10241        data: {{
10242          labels: ['Test Files', 'Non-Test Files'],
10243          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
10244        }},
10245        options: {{
10246          responsive: true, maintainAspectRatio: false, cutout: '62%',
10247          plugins: {{
10248            legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10249            tooltip: {{ callbacks: {{ label: function(ctx) {{
10250              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
10251              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
10252            }} }} }}
10253          }}
10254        }}
10255      }});
10256      ALL_CHARTS.push(filesChart);
10257    }}
10258
10259    function renderCompositionChart(totals) {{
10260      compositionChart = destroyChart(compositionChart);
10261      var canvas = document.getElementById('canvas-composition');
10262      if (!canvas) return;
10263      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
10264      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
10265      showNoData('no-data-composition', false);
10266      compositionChart = new Chart(canvas, {{
10267        type: 'bar',
10268        data: {{
10269          labels: ['Test Functions', 'Assertions', 'Test Suites'],
10270          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
10271        }},
10272        options: {{
10273          responsive: true, maintainAspectRatio: false,
10274          layout: {{ padding: {{ top: 22 }} }},
10275          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
10276          scales: {{
10277            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
10278            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10279          }}
10280        }},
10281        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10282      }});
10283      ALL_CHARTS.push(compositionChart);
10284    }}
10285
10286    function renderCovCharts(covD, tiers) {{
10287      covChart = destroyChart(covChart);
10288      tierChart = destroyChart(tierChart);
10289      var covCanvas = document.getElementById('canvas-cov');
10290      if (covCanvas && covD && covD.length) {{
10291        covChart = new Chart(covCanvas, {{
10292          type: 'bar',
10293          data: {{
10294            labels: covD.map(function(d){{ return d.lang; }}),
10295            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 }}]
10296          }},
10297          options: {{
10298            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10299            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
10300            scales: {{
10301              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
10302              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
10303            }}
10304          }}
10305        }});
10306        ALL_CHARTS.push(covChart);
10307      }}
10308      var tierCanvas = document.getElementById('canvas-cov-tiers');
10309      if (tierCanvas && tiers) {{
10310        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
10311        tierChart = new Chart(tierCanvas, {{
10312          type: 'doughnut',
10313          data: {{
10314            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
10315            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
10316          }},
10317          options: {{
10318            responsive: true, maintainAspectRatio: false, cutout: '62%',
10319            plugins: {{
10320              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
10321              tooltip: {{ callbacks: {{ label: function(ctx) {{
10322                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
10323                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
10324              }} }} }}
10325            }}
10326          }}
10327        }});
10328        ALL_CHARTS.push(tierChart);
10329      }}
10330    }}
10331
10332    function buildLangTable(D) {{
10333      var tbody = document.getElementById('lang-tbody');
10334      if (!tbody) return;
10335      if (!D || !D.length) {{
10336        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>';
10337        return;
10338      }}
10339      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
10340      tbody.innerHTML = D.map(function(d) {{
10341        var barW = Math.round(d.density / maxDensity * 120);
10342        return '<tr>' +
10343          '<td><strong>' + d.lang + '</strong></td>' +
10344          '<td class="num">' + fmt(d.tests) + '</td>' +
10345          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
10346          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
10347          '<td class="num">' + fmt(d.code) + '</td>' +
10348          '<td class="num">' + fmt(d.files) + '</td>' +
10349          '<td class="num">' + d.density.toFixed(2) + '</td>' +
10350          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
10351          '</tr>';
10352      }}).join('');
10353    }}
10354
10355    var covFileData = [];
10356    var covFileTier = 'all';
10357    var covFileSearch = '';
10358
10359    function pctBadge(pct) {{
10360      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
10361      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
10362      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
10363    }}
10364
10365    function buildCovFileTable() {{
10366      var tbody = document.getElementById('cov-file-tbody');
10367      var empty = document.getElementById('cov-file-empty');
10368      var count = document.getElementById('cov-file-count');
10369      if (!tbody) return;
10370      var srch = covFileSearch.toLowerCase();
10371      var filtered = covFileData.filter(function(f) {{
10372        if (covFileTier === 'zero' && f.line_pct > 0) return false;
10373        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
10374        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
10375        if (covFileTier === 'high' && f.line_pct < 80) return false;
10376        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
10377        return true;
10378      }});
10379      if (!filtered.length) {{
10380        tbody.innerHTML = '';
10381        if (empty) empty.style.display = '';
10382        if (count) count.textContent = '';
10383        return;
10384      }}
10385      if (empty) empty.style.display = 'none';
10386      var shown = Math.min(filtered.length, 500);
10387      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
10388      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
10389        var fnCol = f.fn_pct < 0
10390          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
10391          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
10392        return '<tr>' +
10393          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
10394          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
10395          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
10396          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
10397          fnCol +
10398          '</tr>';
10399      }}).join('');
10400    }}
10401
10402    (function() {{
10403      var tabs = document.getElementById('cov-filter-tabs');
10404      if (tabs) {{
10405        tabs.addEventListener('click', function(e) {{
10406          var btn = e.target.closest('.cov-tab');
10407          if (!btn) return;
10408          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
10409          btn.classList.add('active');
10410          covFileTier = btn.getAttribute('data-tier');
10411          buildCovFileTable();
10412        }});
10413      }}
10414      var srch = document.getElementById('cov-file-search');
10415      if (srch) {{
10416        srch.addEventListener('input', function() {{
10417          covFileSearch = this.value;
10418          buildCovFileTable();
10419        }});
10420      }}
10421    }})();
10422
10423    function updateCovGauges(t) {{
10424      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
10425      var el;
10426      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
10427      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
10428      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
10429      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
10430      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
10431      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
10432    }}
10433
10434    function applyScope() {{
10435      var d = getDataset();
10436      var t = d.totals;
10437      var el;
10438      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
10439      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
10440      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
10441      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
10442      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
10443      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
10444      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
10445      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
10446      renderTestCharts(d.lang_tests);
10447      renderAssertionsChart(d.lang_tests);
10448      renderSuitesChart(d.lang_tests);
10449      renderFilesChart(t);
10450      renderCompositionChart(t);
10451      buildLangTable(d.lang_tests);
10452      var covPanel = document.getElementById('cov-panel');
10453      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
10454      if (d.has_coverage) {{
10455        renderCovCharts(d.cov, d.cov_tiers);
10456        updateCovGauges(t);
10457        covFileData = d.file_cov || [];
10458        covFileTier = 'all';
10459        covFileSearch = '';
10460        var tabs = document.getElementById('cov-filter-tabs');
10461        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
10462        var srch = document.getElementById('cov-file-search');
10463        if (srch) srch.value = '';
10464        buildCovFileTable();
10465      }}
10466      loadTrend();
10467    }}
10468
10469    // Populate scope-root-sel from SCOPE_DATA keys
10470    (function() {{
10471      var sel = document.getElementById('scope-root-sel');
10472      if (!sel) return;
10473      Object.keys(SCOPE_DATA).forEach(function(k) {{
10474        if (k === '__all__') return;
10475        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
10476      }});
10477    }})();
10478
10479    document.getElementById('scope-root-sel').addEventListener('change', function() {{
10480      currentRoot = this.value;
10481      currentSub = '';
10482      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
10483      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
10484      var subWrap = document.getElementById('scope-sub-wrap');
10485      var subSel  = document.getElementById('scope-sub-sel');
10486      subSel.innerHTML = '<option value="">Entire project</option>';
10487      if (subNames.length) {{
10488        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
10489        subWrap.style.display = 'flex';
10490      }} else {{
10491        subWrap.style.display = 'none';
10492      }}
10493      applyScope();
10494    }});
10495
10496    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
10497      currentSub = this.value;
10498      applyScope();
10499    }});
10500
10501    function buildTrend(data) {{
10502      var trendCanvas = document.getElementById('canvas-trend');
10503      var trendEmpty  = document.getElementById('trend-empty');
10504      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
10505      pts = pts.slice().reverse();
10506      currentTrendPts = pts;
10507      if (!pts.length) {{
10508        if (trendCanvas) trendCanvas.style.display = 'none';
10509        if (trendEmpty) trendEmpty.style.display = '';
10510        return;
10511      }}
10512      if (trendCanvas) trendCanvas.style.display = '';
10513      if (trendEmpty) trendEmpty.style.display = 'none';
10514      trendChart = destroyChart(trendChart);
10515      if (!trendCanvas) return;
10516      trendChart = new Chart(trendCanvas, {{
10517        type: 'line',
10518        data: {{
10519          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10520          datasets: [{{
10521            label: 'Test Definitions',
10522            data: pts.map(function(d){{ return d.test_count; }}),
10523            borderColor: '#C45C10',
10524            backgroundColor: 'rgba(196,92,16,0.10)',
10525            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10526            pointRadius: 5, fill: true, tension: 0.3
10527          }}]
10528        }},
10529        options: {{
10530          responsive: true, maintainAspectRatio: false,
10531          layout: {{ padding: {{ top: 22 }} }},
10532          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10533          scales: {{
10534            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
10535            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10536          }}
10537        }},
10538        plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10539      }});
10540      ALL_CHARTS.push(trendChart);
10541    }}
10542
10543    // ── Full View expand buttons ──────────────────────────────────────────────
10544    (function() {{
10545      var btn = document.getElementById('tests-expand-btn');
10546      if (!btn) return;
10547      btn.addEventListener('click', function() {{
10548        var D = currentLangTests;
10549        if (!D || !D.length) return;
10550        var top15 = D.slice(0, 15);
10551        var h = Math.max(320, top15.length * 36 + 80);
10552        var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
10553        if (!canvas) return;
10554        new Chart(canvas, {{
10555          type: 'bar',
10556          data: {{
10557            labels: top15.map(function(d){{ return d.lang; }}),
10558            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
10559          }},
10560          options: {{
10561            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10562            layout: {{ padding: {{ right: 72 }} }},
10563            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10564            scales: {{
10565              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10566              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10567            }}
10568          }},
10569          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10570        }});
10571      }});
10572    }})();
10573
10574    (function() {{
10575      var btn = document.getElementById('density-expand-btn');
10576      if (!btn) return;
10577      btn.addEventListener('click', function() {{
10578        var D = currentLangTests;
10579        if (!D || !D.length) return;
10580        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
10581        var h = Math.max(320, topD.length * 36 + 80);
10582        var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
10583        if (!canvas) return;
10584        new Chart(canvas, {{
10585          type: 'bar',
10586          data: {{
10587            labels: topD.map(function(d){{ return d.lang; }}),
10588            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 }}]
10589          }},
10590          options: {{
10591            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10592            layout: {{ padding: {{ right: 72 }} }},
10593            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
10594            scales: {{
10595              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
10596              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10597            }}
10598          }},
10599          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
10600        }});
10601      }});
10602    }})();
10603
10604    (function() {{
10605      var btn = document.getElementById('trend-expand-btn');
10606      if (!btn) return;
10607      btn.addEventListener('click', function() {{
10608        var pts = currentTrendPts;
10609        if (!pts || !pts.length) return;
10610        var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
10611        if (!canvas) return;
10612        new Chart(canvas, {{
10613          type: 'line',
10614          data: {{
10615            labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
10616            datasets: [{{
10617              label: 'Test Definitions',
10618              data: pts.map(function(d){{ return d.test_count; }}),
10619              borderColor: '#C45C10',
10620              backgroundColor: 'rgba(196,92,16,0.10)',
10621              pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
10622              pointRadius: 5, fill: true, tension: 0.3
10623            }}]
10624          }},
10625          options: {{
10626            responsive: true, maintainAspectRatio: false,
10627            layout: {{ padding: {{ top: 22 }} }},
10628            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
10629            scales: {{
10630              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
10631              y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
10632            }}
10633          }},
10634          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
10635        }});
10636      }});
10637    }})();
10638
10639    (function() {{
10640      var btn = document.getElementById('assertions-expand-btn');
10641      if (!btn) return;
10642      btn.addEventListener('click', function() {{
10643        var D = currentLangTests;
10644        if (!D || !D.length) return;
10645        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
10646        if (!top15.length) return;
10647        var h = Math.max(320, top15.length * 36 + 80);
10648        var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
10649        if (!canvas) return;
10650        new Chart(canvas, {{
10651          type: 'bar',
10652          data: {{
10653            labels: top15.map(function(d){{ return d.lang; }}),
10654            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
10655          }},
10656          options: {{
10657            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10658            layout: {{ padding: {{ right: 72 }} }},
10659            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10660            scales: {{
10661              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10662              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10663            }}
10664          }},
10665          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10666        }});
10667      }});
10668    }})();
10669
10670    (function() {{
10671      var btn = document.getElementById('suites-expand-btn');
10672      if (!btn) return;
10673      btn.addEventListener('click', function() {{
10674        var D = currentLangTests;
10675        if (!D || !D.length) return;
10676        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
10677        if (!top15.length) return;
10678        var h = Math.max(320, top15.length * 36 + 80);
10679        var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
10680        if (!canvas) return;
10681        new Chart(canvas, {{
10682          type: 'bar',
10683          data: {{
10684            labels: top15.map(function(d){{ return d.lang; }}),
10685            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 }}]
10686          }},
10687          options: {{
10688            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
10689            layout: {{ padding: {{ right: 72 }} }},
10690            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
10691            scales: {{
10692              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
10693              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
10694            }}
10695          }},
10696          plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
10697        }});
10698      }});
10699    }})();
10700
10701    function loadTrend() {{
10702      var url = '/api/metrics/history?limit=100';
10703      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
10704      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
10705        buildTrend(data);
10706      }}).catch(function(){{
10707        var trendEmpty = document.getElementById('trend-empty');
10708        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
10709      }});
10710    }}
10711
10712    // Re-render charts on theme toggle
10713    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
10714      setTimeout(function() {{
10715        ALL_CHARTS.forEach(function(c) {{
10716          if (c && c.options && c.options.scales) {{
10717            Object.values(c.options.scales).forEach(function(ax) {{
10718              if (ax.grid) ax.grid.color = clr();
10719              if (ax.ticks) ax.ticks.color = txtClr();
10720            }});
10721            c.update();
10722          }}
10723        }});
10724      }}, 80);
10725    }});
10726
10727    applyScope();
10728  }})();
10729  </script>
10730  <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>
10731</body>
10732</html>"#,
10733    );
10734    Html(html).into_response()
10735}
10736
10737// ── Embeddable widget ─────────────────────────────────────────────────────────
10738// Protected. Returns a self-contained HTML page suitable for iframing inside
10739// Jenkins build summaries, Confluence iframe macros, or Jira panels.
10740//
10741// GET /embed/summary?run_id=<uuid>&theme=dark
10742
10743#[derive(Deserialize)]
10744struct EmbedQuery {
10745    run_id: Option<String>,
10746    theme: Option<String>,
10747}
10748
10749async fn embed_handler(
10750    State(state): State<AppState>,
10751    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10752    Query(query): Query<EmbedQuery>,
10753) -> Response {
10754    let entry = {
10755        let reg = state.registry.lock().await;
10756        query.run_id.as_ref().map_or_else(
10757            || reg.entries.first().cloned(),
10758            |id| reg.find_by_run_id(id).cloned(),
10759        )
10760    };
10761
10762    let Some(entry) = entry else {
10763        return Html(
10764            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
10765                .to_string(),
10766        )
10767        .into_response();
10768    };
10769
10770    let dark = query.theme.as_deref() == Some("dark");
10771    let languages: Vec<(String, u64, u64)> = entry
10772        .json_path
10773        .as_ref()
10774        .and_then(|p| read_json(p).ok())
10775        .map(|run| {
10776            run.totals_by_language
10777                .iter()
10778                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
10779                .collect()
10780        })
10781        .unwrap_or_default();
10782
10783    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
10784}
10785
10786fn render_embed_widget(
10787    entry: &RegistryEntry,
10788    languages: &[(String, u64, u64)],
10789    dark: bool,
10790    csp_nonce: &str,
10791) -> String {
10792    let s = &entry.summary;
10793    let total = s.code_lines + s.comment_lines + s.blank_lines;
10794    let code_pct = s
10795        .code_lines
10796        .checked_mul(100)
10797        .and_then(|n| n.checked_div(total))
10798        .unwrap_or(0);
10799
10800    let (bg, fg, surface, muted, border) = if dark {
10801        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
10802    } else {
10803        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
10804    };
10805
10806    let mut lang_rows = String::new();
10807    for (name, files, code) in languages {
10808        write!(
10809            lang_rows,
10810            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
10811            escape_html(name),
10812            format_number(*files),
10813            format_number(*code),
10814        )
10815        .ok();
10816    }
10817
10818    let lang_table = if lang_rows.is_empty() {
10819        String::new()
10820    } else {
10821        format!(
10822            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
10823        )
10824    };
10825
10826    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
10827    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
10828    let project_esc = escape_html(&entry.project_label);
10829    let code_lines = format_number(s.code_lines);
10830    let comment_lines = format_number(s.comment_lines);
10831    let files = format_number(s.files_analyzed);
10832    let code_raw = s.code_lines;
10833    let comment_raw = s.comment_lines;
10834    let blank_raw = s.blank_lines;
10835
10836    format!(
10837        r#"<!doctype html>
10838<html lang="en">
10839<head>
10840  <meta charset="utf-8">
10841  <meta name="viewport" content="width=device-width,initial-scale=1">
10842  <title>OxideSLOC &mdash; {project_esc}</title>
10843  <script src="/static/chart.js"></script>
10844  <style nonce="{csp_nonce}">
10845    *{{box-sizing:border-box;margin:0;padding:0}}
10846    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
10847    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
10848    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
10849    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
10850    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
10851    .card .v{{font-size:18px;font-weight:700}}
10852    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
10853    .row{{display:flex;gap:12px;align-items:flex-start}}
10854    .pie{{width:120px;height:120px;flex-shrink:0}}
10855    .lt{{border-collapse:collapse;width:100%;flex:1}}
10856    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
10857    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
10858    .n{{text-align:right}}
10859    .footer{{margin-top:10px;color:{muted};font-size:10px}}
10860  </style>
10861</head>
10862<body>
10863  <h2>{project_esc}</h2>
10864  <div class="sub">{timestamp} &middot; run {run_short}</div>
10865  <div class="cards">
10866    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
10867    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
10868    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
10869    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
10870  </div>
10871  <div class="row">
10872    <canvas class="pie" id="c"></canvas>
10873    {lang_table}
10874  </div>
10875  <div class="footer">oxide-sloc</div>
10876  <script nonce="{csp_nonce}">
10877    new Chart(document.getElementById('c'),{{
10878      type:'doughnut',
10879      data:{{
10880        labels:['Code','Comments','Blank'],
10881        datasets:[{{
10882          data:[{code_raw},{comment_raw},{blank_raw}],
10883          backgroundColor:['#4a78ee','#b35428','#aaa'],
10884          borderWidth:0
10885        }}]
10886      }},
10887      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
10888    }});
10889  </script>
10890</body>
10891</html>"#
10892    )
10893}
10894
10895fn persist_run_artifacts(
10896    run: &sloc_core::AnalysisRun,
10897    report_html: &str,
10898    run_dir: &Path,
10899    report_title: &str,
10900    file_stem: &str,
10901    result_context: RunResultContext,
10902) -> Result<(RunArtifacts, PendingPdf)> {
10903    // Root dir + organised subdirectories.
10904    let html_dir = run_dir.join("html");
10905    let pdf_dir = run_dir.join("pdf");
10906    let excel_dir = run_dir.join("excel");
10907    let json_dir = run_dir.join("json");
10908    let submodules_dir = run_dir.join("submodules");
10909    for dir in &[
10910        run_dir,
10911        &html_dir,
10912        &pdf_dir,
10913        &excel_dir,
10914        &json_dir,
10915        &submodules_dir,
10916    ] {
10917        fs::create_dir_all(dir)
10918            .with_context(|| format!("failed to create directory {}", dir.display()))?;
10919    }
10920
10921    // HTML report in html/.
10922    let html_path = {
10923        let path = html_dir.join(format!("report_{file_stem}.html"));
10924        fs::write(&path, report_html)
10925            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
10926        Some(path)
10927    };
10928
10929    // JSON result in json/.
10930    let json_path = {
10931        let path = json_dir.join(format!("result_{file_stem}.json"));
10932        let json = serde_json::to_string_pretty(run)
10933            .context("failed to serialize analysis run to JSON")?;
10934        fs::write(&path, json)
10935            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
10936        Some(path)
10937    };
10938
10939    // PDF in pdf/.
10940    let (pdf_path, pending_pdf) = {
10941        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
10942        match write_pdf_from_run(run, &pdf_dest) {
10943            Ok(()) => {
10944                eprintln!(
10945                    "[oxide-sloc][pdf] native PDF written to {}",
10946                    pdf_dest.display()
10947                );
10948                (Some(pdf_dest), None)
10949            }
10950            Err(native_err) => {
10951                eprintln!(
10952                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
10953                );
10954                let source_html_path = html_path
10955                    .as_ref()
10956                    .expect("html_path always Some here")
10957                    .clone();
10958                let pending = Some((source_html_path, pdf_dest.clone(), false));
10959                (Some(pdf_dest), pending)
10960            }
10961        }
10962    };
10963
10964    // CSV and XLSX in excel/.
10965    let csv_path = {
10966        let path = excel_dir.join(format!("report_{file_stem}.csv"));
10967        if let Err(e) = sloc_report::write_csv(run, &path) {
10968            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
10969            None
10970        } else {
10971            Some(path)
10972        }
10973    };
10974
10975    let xlsx_path = {
10976        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
10977        if let Err(e) = sloc_report::write_xlsx(run, &path) {
10978            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
10979            None
10980        } else {
10981            Some(path)
10982        }
10983    };
10984
10985    // Scan config in json/.
10986    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
10987
10988    // Eagerly generate sub-reports before index.html so relative links work.
10989    if run.effective_configuration.discovery.submodule_breakdown {
10990        let run_id = &run.tool.run_id;
10991        for s in &run.submodule_summaries {
10992            build_submodule_row(s, run, run_id, run_dir);
10993        }
10994    }
10995
10996    // index.html at root — offline static export of the result-page dashboard.
10997    generate_offline_index(
10998        run,
10999        run_dir,
11000        file_stem,
11001        html_path.as_deref(),
11002        pdf_path.as_deref(),
11003        json_path.as_deref(),
11004        scan_config_path.as_deref(),
11005        &result_context,
11006    );
11007
11008    Ok((
11009        RunArtifacts {
11010            output_dir: run_dir.to_path_buf(),
11011            html_path,
11012            pdf_path,
11013            json_path,
11014            csv_path,
11015            xlsx_path,
11016            scan_config_path,
11017            report_title: report_title.to_string(),
11018            result_context,
11019        },
11020        pending_pdf,
11021    ))
11022}
11023
11024/// Render a static offline result-page dashboard and write it as `index.html` at
11025/// the root of the run output directory so business users can open it from disk.
11026#[allow(clippy::too_many_arguments)]
11027#[allow(clippy::too_many_lines)]
11028fn generate_offline_index(
11029    run: &sloc_core::AnalysisRun,
11030    run_dir: &Path,
11031    file_stem: &str,
11032    html_path: Option<&Path>,
11033    pdf_path: Option<&Path>,
11034    json_path: Option<&Path>,
11035    scan_config_path: Option<&Path>,
11036    result_context: &RunResultContext,
11037) {
11038    let prev_entry = &result_context.prev_entry;
11039    let prev_scan_count = result_context.prev_scan_count;
11040    let project_path = &result_context.project_path;
11041
11042    let scan_delta = prev_entry.as_ref().and_then(|prev| {
11043        prev.json_path
11044            .as_ref()
11045            .and_then(|p| read_json(p).ok())
11046            .map(|prev_run| compute_delta(&prev_run, run))
11047    });
11048
11049    let files_analyzed = run.per_file_records.len() as u64;
11050    let files_skipped = run.skipped_file_records.len() as u64;
11051    let physical_lines = run
11052        .totals_by_language
11053        .iter()
11054        .map(|r| r.total_physical_lines)
11055        .sum::<u64>();
11056    let code_lines = run
11057        .totals_by_language
11058        .iter()
11059        .map(|r| r.code_lines)
11060        .sum::<u64>();
11061    let comment_lines = run
11062        .totals_by_language
11063        .iter()
11064        .map(|r| r.comment_lines)
11065        .sum::<u64>();
11066    let blank_lines = run
11067        .totals_by_language
11068        .iter()
11069        .map(|r| r.blank_lines)
11070        .sum::<u64>();
11071    let mixed_lines = run
11072        .totals_by_language
11073        .iter()
11074        .map(|r| r.mixed_lines_separate)
11075        .sum::<u64>();
11076    let functions = run
11077        .totals_by_language
11078        .iter()
11079        .map(|r| r.functions)
11080        .sum::<u64>();
11081    let classes = run
11082        .totals_by_language
11083        .iter()
11084        .map(|r| r.classes)
11085        .sum::<u64>();
11086    let variables = run
11087        .totals_by_language
11088        .iter()
11089        .map(|r| r.variables)
11090        .sum::<u64>();
11091    let imports = run
11092        .totals_by_language
11093        .iter()
11094        .map(|r| r.imports)
11095        .sum::<u64>();
11096
11097    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
11098    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
11099    let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
11100    let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
11101    let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
11102    let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
11103    let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
11104    let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
11105
11106    let (delta_fa_str, delta_fa_class) =
11107        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
11108    let (delta_fs_str, delta_fs_class) =
11109        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
11110    let (delta_pl_str, delta_pl_class) =
11111        summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
11112    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
11113    let (delta_cml_str, delta_cml_class) =
11114        summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
11115    let (delta_bl_str, delta_bl_class) =
11116        summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
11117
11118    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
11119    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
11120    let (delta_lines_net_str, delta_lines_net_class) =
11121        match (delta_lines_added, delta_lines_removed) {
11122            (Some(a), Some(r)) => {
11123                let net = a - r;
11124                (fmt_delta(net), delta_class(net).to_string())
11125            }
11126            _ => ("\u{2014}".to_string(), "na".to_string()),
11127        };
11128
11129    let git_commit_url = run
11130        .git_remote_url
11131        .as_deref()
11132        .zip(run.git_commit_long.as_deref())
11133        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
11134    let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
11135        format!(
11136            "{} / {}",
11137            run.environment.initiator_username, run.environment.initiator_hostname
11138        )
11139    });
11140
11141    // Convert absolute path to relative from run_dir (for file:// navigation).
11142    let make_rel = |p: Option<&Path>| -> Option<String> {
11143        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
11144            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
11145    };
11146
11147    let run_id = &run.tool.run_id;
11148
11149    // Submodule rows with relative paths into submodules/.
11150    let submodule_rows: Vec<SubmoduleRow> = run
11151        .submodule_summaries
11152        .iter()
11153        .map(|s| {
11154            let safe = sanitize_project_label(&s.name);
11155            let key = format!("sub_{safe}");
11156            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
11157            SubmoduleRow {
11158                name: s.name.clone(),
11159                relative_path: s.relative_path.clone(),
11160                files_analyzed: s.files_analyzed,
11161                code_lines: s.code_lines,
11162                comment_lines: s.comment_lines,
11163                blank_lines: s.blank_lines,
11164                total_physical_lines: s.total_physical_lines,
11165                html_url: if sub_path.exists() {
11166                    Some(format!("submodules/{key}.html"))
11167                } else {
11168                    None
11169                },
11170            }
11171        })
11172        .collect();
11173
11174    let lang_chart_json = {
11175        let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
11176        langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
11177        let entries: Vec<String> = langs
11178            .into_iter()
11179            .take(12)
11180            .map(|l| {
11181                let name = l.language.display_name()
11182                    .replace('\\', "\\\\")
11183                    .replace('"', "\\\"");
11184                format!(
11185                    r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
11186                    name, l.code_lines, l.comment_lines, l.blank_lines,
11187                    l.total_physical_lines, l.functions, l.classes,
11188                    l.variables, l.imports, l.files
11189                )
11190            })
11191            .collect();
11192        format!("[{}]", entries.join(","))
11193    };
11194
11195    let scan_config_rel =
11196        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
11197
11198    let template = ResultTemplate {
11199        version: env!("CARGO_PKG_VERSION"),
11200        report_title: run.effective_configuration.reporting.report_title.clone(),
11201        project_path: project_path.clone(),
11202        output_dir: display_path(run_dir),
11203        run_id: run_id.clone(),
11204        run_id_short: run_id
11205            .split('-')
11206            .next_back()
11207            .unwrap_or(run_id)
11208            .chars()
11209            .take(7)
11210            .collect(),
11211        files_analyzed,
11212        files_skipped,
11213        physical_lines,
11214        code_lines,
11215        comment_lines,
11216        blank_lines,
11217        mixed_lines,
11218        functions,
11219        classes,
11220        variables,
11221        imports,
11222        html_url: make_rel(html_path),
11223        pdf_url: make_rel(pdf_path),
11224        json_url: make_rel(json_path),
11225        html_download_url: make_rel(html_path),
11226        pdf_download_url: make_rel(pdf_path),
11227        json_download_url: make_rel(json_path),
11228        html_path: html_path.map(display_path),
11229        json_path: json_path.map(display_path),
11230        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
11231        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
11232        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
11233        prev_fa_str,
11234        prev_fs_str,
11235        prev_pl_str,
11236        prev_cl_str,
11237        prev_cml_str,
11238        prev_bl_str,
11239        delta_fa_str,
11240        delta_fa_class: delta_fa_class.to_string(),
11241        delta_fs_str,
11242        delta_fs_class: delta_fs_class.to_string(),
11243        delta_pl_str,
11244        delta_pl_class: delta_pl_class.to_string(),
11245        delta_cl_str,
11246        delta_cl_class: delta_cl_class.to_string(),
11247        delta_cml_str,
11248        delta_cml_class: delta_cml_class.to_string(),
11249        delta_bl_str,
11250        delta_bl_class: delta_bl_class.to_string(),
11251        delta_lines_added,
11252        delta_lines_removed,
11253        delta_lines_net_str,
11254        delta_lines_net_class,
11255        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
11256        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
11257        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
11258        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
11259        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
11260            d.file_deltas
11261                .iter()
11262                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
11263                .map(|f| {
11264                    #[allow(clippy::cast_sign_loss)]
11265                    let n = f.current_code as u64;
11266                    n
11267                })
11268                .sum()
11269        }),
11270        git_branch: run.git_branch.clone(),
11271        git_commit: run.git_commit_short.clone(),
11272        git_commit_long: run.git_commit_long.clone(),
11273        git_author: run.git_commit_author.clone(),
11274        git_commit_url,
11275        scan_performed_by,
11276        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
11277        os_display: format!(
11278            "{} / {}",
11279            run.environment.operating_system, run.environment.architecture
11280        ),
11281        test_count: run.summary_totals.test_count,
11282        current_scan_number: prev_scan_count + 1,
11283        prev_scan_count,
11284        submodule_rows,
11285        pdf_generating: false,
11286        scan_config_url: scan_config_rel,
11287        lang_chart_json,
11288        scatter_chart_json: String::new(),
11289        semantic_chart_json: String::new(),
11290        submodule_chart_json: String::new(),
11291        has_submodule_data: !run.submodule_summaries.is_empty(),
11292        has_semantic_data: run
11293            .totals_by_language
11294            .iter()
11295            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
11296        csp_nonce: String::new(),
11297        confluence_configured: false,
11298        server_mode: false,
11299        report_header_footer: run
11300            .effective_configuration
11301            .reporting
11302            .report_header_footer
11303            .clone(),
11304        is_offline: true,
11305    };
11306
11307    if let Ok(html) = template.render() {
11308        let index_path = run_dir.join("index.html");
11309        if let Err(e) = fs::write(&index_path, html) {
11310            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
11311        }
11312    }
11313}
11314
11315/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
11316/// then root (old flat layout), for backwards compatibility.
11317fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
11318    // New layout: json/scan-config_*.json
11319    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
11320        return Some(found);
11321    }
11322    // Old flat layout: scan-config.json or scan-config_*.json at root
11323    find_scan_config_in_dir_flat(dir)
11324}
11325
11326fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
11327    let exact = dir.join("scan-config.json");
11328    if exact.exists() {
11329        return Some(exact);
11330    }
11331    fs::read_dir(dir).ok().and_then(|entries| {
11332        entries
11333            .filter_map(std::result::Result::ok)
11334            .find(|e| {
11335                let name = e.file_name();
11336                let name = name.to_string_lossy();
11337                name.starts_with("scan-config") && name.ends_with(".json")
11338            })
11339            .map(|e| e.path())
11340    })
11341}
11342
11343// ── Config export / import ────────────────────────────────────────────────────
11344
11345async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
11346    let toml_str = match toml::to_string_pretty(&state.base_config) {
11347        Ok(s) => s,
11348        Err(e) => {
11349            return (
11350                StatusCode::INTERNAL_SERVER_ERROR,
11351                format!("serialization error: {e}"),
11352            )
11353                .into_response();
11354        }
11355    };
11356    (
11357        [
11358            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
11359            (
11360                header::CONTENT_DISPOSITION,
11361                "attachment; filename=\".oxide-sloc.toml\"",
11362            ),
11363        ],
11364        toml_str,
11365    )
11366        .into_response()
11367}
11368
11369#[derive(Serialize)]
11370struct OkResponse {
11371    ok: bool,
11372}
11373
11374#[derive(Serialize)]
11375struct SaveProfileResponse {
11376    ok: bool,
11377    id: String,
11378}
11379
11380#[derive(Serialize)]
11381struct ProfileListResponse {
11382    profiles: Vec<ScanProfile>,
11383}
11384
11385#[derive(Serialize)]
11386struct ImportConfigResponse {
11387    ok: bool,
11388    config: sloc_config::AppConfig,
11389}
11390
11391#[derive(Deserialize)]
11392struct ImportConfigBody {
11393    toml: String,
11394}
11395
11396async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
11397    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
11398        Ok(config) => {
11399            if let Err(e) = config.validate() {
11400                return error::unprocessable_entity(&e.to_string());
11401            }
11402            Json(ImportConfigResponse { ok: true, config }).into_response()
11403        }
11404        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
11405    }
11406}
11407
11408// ── Scan profiles API ─────────────────────────────────────────────────────────
11409
11410async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
11411    let store = state.scan_profiles.lock().await;
11412    Json(ProfileListResponse {
11413        profiles: store.profiles.clone(),
11414    })
11415}
11416
11417#[derive(Deserialize)]
11418struct SaveScanProfileBody {
11419    name: String,
11420    params: serde_json::Value,
11421}
11422
11423async fn api_save_scan_profile(
11424    State(state): State<AppState>,
11425    Json(body): Json<SaveScanProfileBody>,
11426) -> impl IntoResponse {
11427    if body.name.trim().is_empty() {
11428        return error::bad_request("name must not be empty");
11429    }
11430
11431    let id = uuid::Uuid::new_v4().to_string();
11432    let profile = ScanProfile {
11433        id: id.clone(),
11434        name: body.name.trim().to_string(),
11435        created_at: chrono::Utc::now().to_rfc3339(),
11436        params: body.params,
11437    };
11438
11439    let mut store = state.scan_profiles.lock().await;
11440    store.profiles.push(profile);
11441    if let Err(e) = store.save(&state.scan_profiles_path) {
11442        tracing::warn!("failed to persist scan profiles: {e}");
11443    }
11444    drop(store);
11445
11446    (
11447        StatusCode::CREATED,
11448        Json(SaveProfileResponse { ok: true, id }),
11449    )
11450        .into_response()
11451}
11452
11453async fn api_delete_scan_profile(
11454    State(state): State<AppState>,
11455    AxumPath(id): AxumPath<String>,
11456) -> impl IntoResponse {
11457    let mut store = state.scan_profiles.lock().await;
11458    let before = store.profiles.len();
11459    store.profiles.retain(|p| p.id != id);
11460    if store.profiles.len() == before {
11461        drop(store);
11462        return error::not_found("profile not found");
11463    }
11464    if let Err(e) = store.save(&state.scan_profiles_path) {
11465        tracing::warn!("failed to persist scan profiles: {e}");
11466    }
11467    drop(store);
11468    Json(OkResponse { ok: true }).into_response()
11469}
11470
11471fn resolve_output_root(raw: Option<&str>) -> PathBuf {
11472    let value = raw.unwrap_or("out/web").trim();
11473    let path = if value.is_empty() {
11474        PathBuf::from("out/web")
11475    } else {
11476        PathBuf::from(value)
11477    };
11478
11479    if path.is_absolute() {
11480        path
11481    } else {
11482        workspace_root().join(path)
11483    }
11484}
11485
11486/// Derive the directory that holds remote-repo clones from the output root.
11487fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
11488    std::env::var("SLOC_GIT_CLONES_DIR")
11489        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
11490}
11491
11492/// Build a deterministic filesystem path for a cloned remote repository.
11493/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
11494pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
11495    let safe: String = repo_url
11496        .chars()
11497        .map(|c| {
11498            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
11499                c
11500            } else {
11501                '_'
11502            }
11503        })
11504        .take(80)
11505        .collect();
11506    clones_dir.join(safe)
11507}
11508
11509/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
11510/// Runs synchronously — call from `tokio::task::spawn_blocking`.
11511pub(crate) fn scan_path_to_artifacts(
11512    scan_path: &Path,
11513    base_config: &AppConfig,
11514    label: &str,
11515) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
11516    let mut config = base_config.clone();
11517    config.discovery.root_paths = vec![scan_path.to_path_buf()];
11518    label.clone_into(&mut config.reporting.report_title);
11519    let run = analyze(&config, "git", None, None)?;
11520    let html = render_html(&run)?;
11521    let run_id = run.tool.run_id.clone();
11522    let project_label = sanitize_project_label(label);
11523    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
11524    let file_stem = {
11525        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
11526        if commit.is_empty() {
11527            project_label
11528        } else {
11529            format!("{project_label}_{commit}")
11530        }
11531    };
11532    let (artifacts, _pending_pdf) = persist_run_artifacts(
11533        &run,
11534        &html,
11535        &output_dir,
11536        label,
11537        &file_stem,
11538        RunResultContext::default(),
11539    )?;
11540    Ok((run_id, artifacts, run))
11541}
11542
11543/// Re-spawn background poll tasks for any polling schedules saved to disk.
11544async fn restart_poll_schedules(state: &AppState) {
11545    let store = state.schedules.lock().await;
11546    let poll_schedules: Vec<_> = store
11547        .schedules
11548        .iter()
11549        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
11550        .cloned()
11551        .collect();
11552    drop(store);
11553    for schedule in poll_schedules {
11554        let interval = schedule.interval_secs.unwrap_or(300);
11555        let st = state.clone();
11556        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
11557    }
11558}
11559
11560fn split_patterns(raw: Option<&str>) -> Vec<String> {
11561    raw.unwrap_or("")
11562        .lines()
11563        .flat_map(|line| line.split(','))
11564        .map(str::trim)
11565        .filter(|part| !part.is_empty())
11566        .map(ToOwned::to_owned)
11567        .collect()
11568}
11569
11570pub fn build_sub_run(
11571    parent: &AnalysisRun,
11572    sub: &sloc_core::SubmoduleSummary,
11573    parent_path: &str,
11574) -> AnalysisRun {
11575    let sub_files: Vec<_> = parent
11576        .per_file_records
11577        .iter()
11578        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
11579        .cloned()
11580        .collect();
11581    let mut config = parent.effective_configuration.clone();
11582    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
11583    AnalysisRun {
11584        tool: parent.tool.clone(),
11585        environment: parent.environment.clone(),
11586        effective_configuration: config,
11587        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
11588        summary_totals: SummaryTotals {
11589            files_considered: sub.files_analyzed,
11590            files_analyzed: sub.files_analyzed,
11591            files_skipped: 0,
11592            total_physical_lines: sub.total_physical_lines,
11593            code_lines: sub.code_lines,
11594            comment_lines: sub.comment_lines,
11595            blank_lines: sub.blank_lines,
11596            mixed_lines_separate: 0,
11597            functions: 0,
11598            classes: 0,
11599            variables: 0,
11600            imports: 0,
11601            test_count: 0,
11602            test_assertion_count: 0,
11603            test_suite_count: 0,
11604            coverage_lines_found: 0,
11605            coverage_lines_hit: 0,
11606            coverage_functions_found: 0,
11607            coverage_functions_hit: 0,
11608            coverage_branches_found: 0,
11609            coverage_branches_hit: 0,
11610        },
11611        totals_by_language: sub.language_summaries.clone(),
11612        per_file_records: sub_files,
11613        skipped_file_records: vec![],
11614        warnings: vec![],
11615        submodule_summaries: vec![],
11616        git_commit_short: parent.git_commit_short.clone(),
11617        git_commit_long: parent.git_commit_long.clone(),
11618        git_branch: parent.git_branch.clone(),
11619        git_commit_author: parent.git_commit_author.clone(),
11620        git_commit_date: parent.git_commit_date.clone(),
11621        git_tags: parent.git_tags.clone(),
11622        git_nearest_tag: parent.git_nearest_tag.clone(),
11623        git_remote_url: parent.git_remote_url.clone(),
11624        style_summary: None,
11625    }
11626}
11627
11628pub fn sanitize_project_label(raw: &str) -> String {
11629    let candidate = Path::new(raw)
11630        .file_name()
11631        .and_then(|name| name.to_str())
11632        .unwrap_or("project");
11633
11634    let mut value = String::with_capacity(candidate.len());
11635    for ch in candidate.chars() {
11636        if ch.is_ascii_alphanumeric() {
11637            value.push(ch.to_ascii_lowercase());
11638        } else {
11639            value.push('-');
11640        }
11641    }
11642
11643    let compact = value.trim_matches('-').to_string();
11644    if compact.is_empty() {
11645        "project".to_string()
11646    } else {
11647        compact
11648    }
11649}
11650
11651/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
11652/// comparisons with non-canonicalized stored paths work correctly.
11653fn strip_unc_prefix(path: PathBuf) -> PathBuf {
11654    let s = path.to_string_lossy();
11655    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11656        return PathBuf::from(format!(r"\\{rest}"));
11657    }
11658    if let Some(rest) = s.strip_prefix(r"\\?\") {
11659        return PathBuf::from(rest);
11660    }
11661    path
11662}
11663
11664/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
11665/// commit page URL for the most common hosting platforms.
11666fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
11667    let base = if let Some(rest) = remote.strip_prefix("git@") {
11668        let (host, path) = rest.split_once(':')?;
11669        format!("https://{}/{}", host, path.trim_end_matches(".git"))
11670    } else if remote.starts_with("https://") || remote.starts_with("http://") {
11671        remote
11672            .trim_end_matches('/')
11673            .trim_end_matches(".git")
11674            .to_owned()
11675    } else {
11676        return None;
11677    };
11678    let base = base.trim_end_matches('/');
11679    // GitLab uses /-/commit/; everything else uses /commit/
11680    if base.contains("gitlab.com") || base.contains("gitlab.") {
11681        Some(format!("{}/-/commit/{}", base, sha))
11682    } else if base.contains("bitbucket.org") {
11683        Some(format!("{}/commits/{}", base, sha))
11684    } else {
11685        Some(format!("{}/commit/{}", base, sha))
11686    }
11687}
11688
11689fn display_path(path: &Path) -> String {
11690    let s = path.to_string_lossy();
11691    // Strip Windows extended-length prefix for display only; the underlying
11692    // PathBuf remains unchanged so file operations are unaffected.
11693    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
11694    // \\?\C:\path           →  C:\path          (local drive)
11695    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
11696        return format!(r"\\{rest}");
11697    }
11698    if let Some(rest) = s.strip_prefix(r"\\?\") {
11699        return rest.to_owned();
11700    }
11701    s.into_owned()
11702}
11703
11704fn sanitize_path_str(s: &str) -> String {
11705    // Forward-slash variants of the Windows extended-length prefix that appear
11706    // when paths stored as plain strings have been processed through some path
11707    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
11708    if let Some(rest) = s.strip_prefix("//?/UNC/") {
11709        return format!("//{rest}");
11710    }
11711    if let Some(rest) = s.strip_prefix("//?/") {
11712        return rest.to_owned();
11713    }
11714    display_path(Path::new(s))
11715}
11716
11717fn workspace_root() -> PathBuf {
11718    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
11719    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
11720        let p = PathBuf::from(root);
11721        if p.is_dir() {
11722            return p;
11723        }
11724    }
11725
11726    // Current working directory — works for `cargo run` from the project root
11727    // and for scripts/run.sh which cds there first.
11728    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
11729}
11730
11731/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
11732fn make_git_label(repo: &str, ref_name: &str) -> String {
11733    if repo.is_empty() || ref_name.is_empty() {
11734        return String::new();
11735    }
11736    let base = repo
11737        .trim_end_matches('/')
11738        .trim_end_matches(".git")
11739        .rsplit('/')
11740        .next()
11741        .unwrap_or("repo");
11742    let ref_safe: String = ref_name
11743        .chars()
11744        .map(|c| {
11745            if c.is_alphanumeric() || c == '-' || c == '.' {
11746                c
11747            } else {
11748                '_'
11749            }
11750        })
11751        .collect();
11752    format!("{base}_at_{ref_safe}_sloc")
11753}
11754
11755/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
11756fn desktop_dir() -> PathBuf {
11757    if let Ok(profile) = std::env::var("USERPROFILE") {
11758        let p = PathBuf::from(profile).join("Desktop");
11759        if p.exists() {
11760            return p;
11761        }
11762    }
11763    if let Ok(home) = std::env::var("HOME") {
11764        let p = PathBuf::from(home).join("Desktop");
11765        if p.exists() {
11766            return p;
11767        }
11768    }
11769    workspace_root().join("out").join("web")
11770}
11771
11772fn resolve_input_path(raw: &str) -> PathBuf {
11773    let trimmed = raw.trim();
11774    if trimmed.is_empty() {
11775        return workspace_root().join("samples").join("basic");
11776    }
11777
11778    let candidate = PathBuf::from(trimmed);
11779    let resolved = if candidate.is_absolute() {
11780        candidate
11781    } else {
11782        let rooted = workspace_root().join(&candidate);
11783        if rooted.exists() {
11784            rooted
11785        } else {
11786            workspace_root().join(candidate)
11787        }
11788    };
11789
11790    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
11791    // strip that prefix so stored paths and the displayed "Project path" are clean.
11792    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
11793    PathBuf::from(display_path(&canonical))
11794}
11795
11796fn dir_size_bytes(path: &Path) -> u64 {
11797    let mut total = 0u64;
11798    if let Ok(rd) = fs::read_dir(path) {
11799        for entry in rd.filter_map(Result::ok) {
11800            let p = entry.path();
11801            if p.is_file() {
11802                if let Ok(meta) = p.metadata() {
11803                    total += meta.len();
11804                }
11805            } else if p.is_dir() {
11806                total += dir_size_bytes(&p);
11807            }
11808        }
11809    }
11810    total
11811}
11812
11813#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
11814fn format_dir_size(bytes: u64) -> String {
11815    if bytes >= 1_073_741_824 {
11816        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
11817    } else if bytes >= 1_048_576 {
11818        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
11819    } else if bytes >= 1_024 {
11820        format!("{:.0} KB", bytes as f64 / 1_024.0)
11821    } else {
11822        format!("{bytes} B")
11823    }
11824}
11825
11826fn render_submodule_chips(
11827    root: &Path,
11828    submodules: &[(String, std::path::PathBuf)],
11829    out: &mut String,
11830) {
11831    use std::fmt::Write as _;
11832    let count = submodules.len();
11833    out.push_str(r#"<div class="submodule-preview-strip">"#);
11834    write!(
11835        out,
11836        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>"#,
11837        if count == 1 { "" } else { "s" }
11838    )
11839    .ok();
11840    out.push_str(r#"<div class="submodule-preview-chips">"#);
11841    for (sub_name, sub_rel_path) in submodules {
11842        let sub_abs = root.join(sub_rel_path);
11843        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
11844        let mut sub_stats = PreviewStats::default();
11845        let mut sub_rows: Vec<PreviewRow> = Vec::new();
11846        let mut sub_langs: Vec<&'static str> = Vec::new();
11847        let mut sub_budget = PreviewBudget {
11848            shown: 0,
11849            max_entries: 2000,
11850            max_depth: 9,
11851        };
11852        let mut sub_next_id = 1usize;
11853        let _ = collect_preview_rows(
11854            &sub_abs,
11855            &sub_abs,
11856            0,
11857            None,
11858            &mut sub_next_id,
11859            &mut sub_budget,
11860            &mut sub_stats,
11861            &mut sub_rows,
11862            &mut sub_langs,
11863            &[],
11864            &[],
11865        );
11866        let stats_json = format!(
11867            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
11868            sub_stats.directories,
11869            sub_stats.files,
11870            sub_stats.supported,
11871            sub_stats.skipped,
11872            sub_stats.unsupported
11873        );
11874        write!(
11875            out,
11876            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>"#,
11877            escape_html(sub_name),
11878            escape_html(&sub_rel_path.to_string_lossy()),
11879            escape_html(&sub_size),
11880            escape_html(&stats_json),
11881            escape_html(sub_name),
11882            escape_html(&sub_size),
11883        )
11884        .ok();
11885    }
11886    out.push_str(
11887        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
11888    );
11889    out.push_str(r"</div>");
11890}
11891
11892fn render_language_pills_row(languages: &[&str], out: &mut String) {
11893    use std::fmt::Write as _;
11894    if languages.is_empty() {
11895        out.push_str(
11896            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
11897        );
11898        return;
11899    }
11900    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
11901    for language in languages {
11902        if let Some(icon) = language_icon_file(language) {
11903            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();
11904        } else if let Some(svg) = language_inline_svg(language) {
11905            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();
11906        } else {
11907            write!(
11908                out,
11909                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
11910                escape_html(&language.to_ascii_lowercase()),
11911                escape_html(language)
11912            )
11913            .ok();
11914        }
11915    }
11916}
11917
11918#[allow(clippy::too_many_lines)]
11919fn build_preview_html(
11920    root: &Path,
11921    include_patterns: &[String],
11922    exclude_patterns: &[String],
11923) -> Result<String> {
11924    if !root.exists() {
11925        return Ok(format!(
11926            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
11927            escape_html(&display_path(root))
11928        ));
11929    }
11930
11931    let _selected = display_path(root);
11932    let mut stats = PreviewStats::default();
11933    let mut rows = Vec::new();
11934    let mut languages = Vec::new();
11935    let mut budget = PreviewBudget {
11936        shown: 0,
11937        max_entries: 600,
11938        max_depth: 9,
11939    };
11940    let mut next_row_id = 1usize;
11941
11942    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
11943        || root.to_string_lossy().into_owned(),
11944        std::string::ToString::to_string,
11945    );
11946    let root_modified = root
11947        .metadata()
11948        .ok()
11949        .and_then(|meta| meta.modified().ok())
11950        .map_or_else(|| "-".to_string(), format_system_time);
11951
11952    rows.push(PreviewRow {
11953        row_id: 0,
11954        parent_row_id: None,
11955        depth: 0,
11956        name: format!("{root_name}/"),
11957        kind: PreviewKind::Dir,
11958        is_dir: true,
11959        language: None,
11960        modified: root_modified,
11961        type_label: "Directory".to_string(),
11962    });
11963    collect_preview_rows(
11964        root,
11965        root,
11966        0,
11967        Some(0),
11968        &mut next_row_id,
11969        &mut budget,
11970        &mut stats,
11971        &mut rows,
11972        &mut languages,
11973        include_patterns,
11974        exclude_patterns,
11975    )?;
11976
11977    let root_size = format_dir_size(dir_size_bytes(root));
11978
11979    let mut out = String::new();
11980    write!(
11981        out,
11982        r#"<div class="explorer-wrap" data-project-size="{}">"#,
11983        escape_html(&root_size)
11984    )
11985    .ok();
11986    out.push_str(r#"<div class="explorer-toolbar compact">"#);
11987    out.push_str(r#"<div class="explorer-title-group">"#);
11988    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
11989    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
11990    out.push_str(r"</div></div>");
11991
11992    out.push_str(r#"<div class="scope-stats">"#);
11993    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();
11994    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();
11995    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();
11996    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();
11997    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();
11998    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>"#);
11999    out.push_str(r"</div>");
12000
12001    let submodules = sloc_core::detect_submodules(root);
12002    if !submodules.is_empty() {
12003        render_submodule_chips(root, &submodules, &mut out);
12004    }
12005
12006    out.push_str(r#"<div class="scope-info-row">"#);
12007    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
12008    render_language_pills_row(&languages, &mut out);
12009    out.push_str(r"</div></div>");
12010    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>"#);
12011    out.push_str(r"</div>");
12012
12013    out.push_str(r#"<div class="file-explorer-shell">"#);
12014    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>"#);
12015    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>"#);
12016    out.push_str(r#"<div class="file-explorer-tree">"#);
12017    for row in rows {
12018        let status_label = row.kind.label();
12019        let lang_attr = row.language.unwrap_or("");
12020        let toggle_html = if row.is_dir {
12021            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
12022                .to_string()
12023        } else {
12024            r#"<span class="tree-bullet">•</span>"#.to_string()
12025        };
12026        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();
12027    }
12028    if budget.shown >= budget.max_entries {
12029        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>"#);
12030    }
12031    out.push_str(r"</div></div></div>");
12032
12033    Ok(out)
12034}
12035
12036#[derive(Default)]
12037struct PreviewStats {
12038    directories: usize,
12039    files: usize,
12040    supported: usize,
12041    skipped: usize,
12042    unsupported: usize,
12043}
12044
12045struct PreviewRow {
12046    row_id: usize,
12047    parent_row_id: Option<usize>,
12048    depth: usize,
12049    name: String,
12050    kind: PreviewKind,
12051    is_dir: bool,
12052    language: Option<&'static str>,
12053    modified: String,
12054    type_label: String,
12055}
12056
12057#[derive(Copy, Clone)]
12058enum PreviewKind {
12059    Dir,
12060    Supported,
12061    Skipped,
12062    Unsupported,
12063}
12064
12065impl PreviewKind {
12066    const fn filter_key(self) -> &'static str {
12067        match self {
12068            Self::Dir => "dir",
12069            Self::Supported => "supported",
12070            Self::Skipped => "skipped",
12071            Self::Unsupported => "unsupported",
12072        }
12073    }
12074
12075    const fn label(self) -> &'static str {
12076        match self {
12077            Self::Dir => "dir",
12078            Self::Supported => "supported",
12079            Self::Skipped => "skipped by policy",
12080            Self::Unsupported => "unsupported",
12081        }
12082    }
12083
12084    const fn badge_class(self) -> &'static str {
12085        match self {
12086            Self::Dir => "badge badge-dir",
12087            Self::Supported => "badge badge-scan",
12088            Self::Skipped => "badge badge-skip",
12089            Self::Unsupported => "badge badge-unsupported",
12090        }
12091    }
12092
12093    const fn node_class(self) -> &'static str {
12094        match self {
12095            Self::Dir => "tree-node-dir",
12096            Self::Supported => "tree-node-supported",
12097            Self::Skipped => "tree-node-skipped",
12098            Self::Unsupported => "tree-node-unsupported",
12099        }
12100    }
12101}
12102
12103struct PreviewBudget {
12104    shown: usize,
12105    max_entries: usize,
12106    max_depth: usize,
12107}
12108
12109/// Handle a single directory entry inside `collect_preview_rows`.
12110/// Returns `true` when the entry was handled (caller should `continue`).
12111#[allow(clippy::too_many_arguments)]
12112fn handle_preview_dir_entry(
12113    root: &Path,
12114    path: &Path,
12115    name: &str,
12116    modified: String,
12117    depth: usize,
12118    parent_row_id: Option<usize>,
12119    row_id: usize,
12120    next_row_id: &mut usize,
12121    budget: &mut PreviewBudget,
12122    stats: &mut PreviewStats,
12123    rows: &mut Vec<PreviewRow>,
12124    languages: &mut Vec<&'static str>,
12125    include_patterns: &[String],
12126    exclude_patterns: &[String],
12127) -> Result<()> {
12128    let relative = preview_relative_path(root, path);
12129    if should_skip_preview_directory(&relative, exclude_patterns) {
12130        return Ok(());
12131    }
12132    stats.directories += 1;
12133    rows.push(PreviewRow {
12134        row_id,
12135        parent_row_id,
12136        depth: depth + 1,
12137        name: format!("{name}/"),
12138        kind: PreviewKind::Dir,
12139        is_dir: true,
12140        language: None,
12141        modified,
12142        type_label: "Directory".to_string(),
12143    });
12144    budget.shown += 1;
12145    if !matches!(name, ".git" | "node_modules" | "target") {
12146        collect_preview_rows(
12147            root,
12148            path,
12149            depth + 1,
12150            Some(row_id),
12151            next_row_id,
12152            budget,
12153            stats,
12154            rows,
12155            languages,
12156            include_patterns,
12157            exclude_patterns,
12158        )?;
12159    }
12160    Ok(())
12161}
12162
12163/// Handle a single file entry inside `collect_preview_rows`.
12164#[allow(clippy::too_many_arguments)]
12165fn handle_preview_file_entry(
12166    root: &Path,
12167    path: &Path,
12168    name: &str,
12169    modified: String,
12170    depth: usize,
12171    parent_row_id: Option<usize>,
12172    row_id: usize,
12173    budget: &mut PreviewBudget,
12174    stats: &mut PreviewStats,
12175    rows: &mut Vec<PreviewRow>,
12176    languages: &mut Vec<&'static str>,
12177    include_patterns: &[String],
12178    exclude_patterns: &[String],
12179) {
12180    let relative = preview_relative_path(root, path);
12181    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
12182        return;
12183    }
12184    stats.files += 1;
12185    let kind = classify_preview_file(name);
12186    match kind {
12187        PreviewKind::Supported => stats.supported += 1,
12188        PreviewKind::Skipped => stats.skipped += 1,
12189        PreviewKind::Unsupported => stats.unsupported += 1,
12190        PreviewKind::Dir => {}
12191    }
12192    let language = detect_language_name(name);
12193    if let Some(lang) = language {
12194        if !languages.contains(&lang) {
12195            languages.push(lang);
12196        }
12197    }
12198    rows.push(PreviewRow {
12199        row_id,
12200        parent_row_id,
12201        depth: depth + 1,
12202        name: name.to_owned(),
12203        kind,
12204        is_dir: false,
12205        language,
12206        modified,
12207        type_label: preview_type_label(name, language, kind),
12208    });
12209    budget.shown += 1;
12210}
12211
12212#[allow(clippy::too_many_arguments)]
12213#[allow(clippy::too_many_lines)]
12214fn collect_preview_rows(
12215    root: &Path,
12216    dir: &Path,
12217    depth: usize,
12218    parent_row_id: Option<usize>,
12219    next_row_id: &mut usize,
12220    budget: &mut PreviewBudget,
12221    stats: &mut PreviewStats,
12222    rows: &mut Vec<PreviewRow>,
12223    languages: &mut Vec<&'static str>,
12224    include_patterns: &[String],
12225    exclude_patterns: &[String],
12226) -> Result<()> {
12227    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
12228        return Ok(());
12229    }
12230
12231    let mut entries = fs::read_dir(dir)
12232        .with_context(|| format!("failed to read directory {}", dir.display()))?
12233        .filter_map(std::result::Result::ok)
12234        .collect::<Vec<_>>();
12235    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
12236
12237    for entry in entries {
12238        if budget.shown >= budget.max_entries {
12239            break;
12240        }
12241
12242        let path = entry.path();
12243        let name = entry.file_name().to_string_lossy().into_owned();
12244        let Ok(metadata) = entry.metadata() else {
12245            continue;
12246        };
12247        let row_id = *next_row_id;
12248        *next_row_id += 1;
12249        let modified = metadata
12250            .modified()
12251            .ok()
12252            .map_or_else(|| "-".to_string(), format_system_time);
12253
12254        if metadata.is_dir() {
12255            handle_preview_dir_entry(
12256                root,
12257                &path,
12258                &name,
12259                modified,
12260                depth,
12261                parent_row_id,
12262                row_id,
12263                next_row_id,
12264                budget,
12265                stats,
12266                rows,
12267                languages,
12268                include_patterns,
12269                exclude_patterns,
12270            )?;
12271            continue;
12272        }
12273
12274        if metadata.is_file() {
12275            handle_preview_file_entry(
12276                root,
12277                &path,
12278                &name,
12279                modified,
12280                depth,
12281                parent_row_id,
12282                row_id,
12283                budget,
12284                stats,
12285                rows,
12286                languages,
12287                include_patterns,
12288                exclude_patterns,
12289            );
12290        }
12291    }
12292
12293    Ok(())
12294}
12295
12296fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
12297    if let Some(language) = language {
12298        return format!("{language} source");
12299    }
12300    let lower = name.to_ascii_lowercase();
12301    let ext = Path::new(&lower)
12302        .extension()
12303        .and_then(|e| e.to_str())
12304        .unwrap_or("");
12305    match kind {
12306        PreviewKind::Skipped => {
12307            if lower.ends_with(".min.js") {
12308                "Minified asset".to_string()
12309            } else if [
12310                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
12311            ]
12312            .contains(&ext)
12313            {
12314                "Binary or archive".to_string()
12315            } else {
12316                "Skipped file".to_string()
12317            }
12318        }
12319        PreviewKind::Unsupported => {
12320            if ext.is_empty() {
12321                "Unsupported file".to_string()
12322            } else {
12323                format!("{} file", ext.to_ascii_uppercase())
12324            }
12325        }
12326        PreviewKind::Supported => "Supported source".to_string(),
12327        PreviewKind::Dir => "Directory".to_string(),
12328    }
12329}
12330
12331fn format_system_time(time: SystemTime) -> String {
12332    #[allow(clippy::cast_possible_wrap)]
12333    let secs = match time.duration_since(UNIX_EPOCH) {
12334        Ok(duration) => duration.as_secs() as i64,
12335        Err(_) => return "-".to_string(),
12336    };
12337    let days = secs.div_euclid(86_400);
12338    let secs_of_day = secs.rem_euclid(86_400);
12339    let (year, month, day) = civil_from_days(days);
12340    let hour = secs_of_day / 3_600;
12341    let minute = (secs_of_day % 3_600) / 60;
12342    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
12343}
12344
12345#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
12346fn civil_from_days(days: i64) -> (i32, u32, u32) {
12347    let z = days + 719_468;
12348    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
12349    let doe = z - era * 146_097;
12350    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
12351    let y = yoe + era * 400;
12352    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
12353    let mp = (5 * doy + 2) / 153;
12354    let d = doy - (153 * mp + 2) / 5 + 1;
12355    let m = mp + if mp < 10 { 3 } else { -9 };
12356    let year = y + i64::from(m <= 2);
12357    (year as i32, m as u32, d as u32)
12358}
12359
12360// The input is already lowercased via `to_ascii_lowercase()` before calling
12361// `ends_with`, so the comparisons are inherently case-insensitive.
12362#[allow(clippy::case_sensitive_file_extension_comparisons)]
12363fn detect_language_name(name: &str) -> Option<&'static str> {
12364    let lower = name.to_ascii_lowercase();
12365    if lower.ends_with(".c") || lower.ends_with(".h") {
12366        Some("C")
12367    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
12368        .iter()
12369        .any(|s| lower.ends_with(s))
12370    {
12371        Some("C++")
12372    } else if lower.ends_with(".cs") {
12373        Some("C#")
12374    } else if lower.ends_with(".py") {
12375        Some("Python")
12376    } else if lower.ends_with(".sh") {
12377        Some("Shell")
12378    } else if [".ps1", ".psm1", ".psd1"]
12379        .iter()
12380        .any(|s| lower.ends_with(s))
12381    {
12382        Some("PowerShell")
12383    } else {
12384        None
12385    }
12386}
12387
12388fn language_icon_file(language: &str) -> Option<&'static str> {
12389    match language {
12390        "C" => Some("c.png"),
12391        "C++" => Some("cpp.png"),
12392        "C#" => Some("c-sharp.png"),
12393        "Python" => Some("python.png"),
12394        "Shell" => Some("shell.png"),
12395        "PowerShell" => Some("powershell.png"),
12396        "JavaScript" => Some("java-script.png"),
12397        "HTML" => Some("html-5.png"),
12398        "Java" => Some("java.png"),
12399        "Visual Basic" => Some("visual-basic.png"),
12400        "Assembly" => Some("asm.png"),
12401        "Go" => Some("go.png"),
12402        "R" => Some("r.png"),
12403        "XML" => Some("xml.png"),
12404        "Groovy" => Some("groovy.png"),
12405        "Dockerfile" => Some("docker.png"),
12406        "Makefile" => Some("makefile.svg"),
12407        "Perl" => Some("perl.svg"),
12408        _ => None,
12409    }
12410}
12411
12412// Inline SVG badges for languages that have no PNG icon in images/icons/.
12413// Using inline SVG keeps the web UI fully self-contained — no extra files
12414// needed on disk, no 404s on air-gapped deployments.
12415// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
12416fn language_inline_svg(language: &str) -> Option<&'static str> {
12417    match language {
12418        "Rust" => Some(
12419            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>"##,
12420        ),
12421        "TypeScript" => Some(
12422            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>"##,
12423        ),
12424        _ => None,
12425    }
12426}
12427
12428// The input is already lowercased via `to_ascii_lowercase()` before the
12429// `ends_with` calls, so these comparisons are inherently case-insensitive.
12430#[allow(clippy::case_sensitive_file_extension_comparisons)]
12431fn classify_preview_file(name: &str) -> PreviewKind {
12432    let lower = name.to_ascii_lowercase();
12433
12434    let scannable = [
12435        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
12436        ".psm1", ".psd1",
12437    ]
12438    .iter()
12439    .any(|suffix| lower.ends_with(suffix));
12440
12441    if scannable {
12442        PreviewKind::Supported
12443    } else if lower.ends_with(".min.js")
12444        || lower.ends_with(".lock")
12445        || lower.ends_with(".png")
12446        || lower.ends_with(".jpg")
12447        || lower.ends_with(".jpeg")
12448        || lower.ends_with(".gif")
12449        || lower.ends_with(".zip")
12450        || lower.ends_with(".pdf")
12451        || lower.ends_with(".pyc")
12452        || lower.ends_with(".xz")
12453        || lower.ends_with(".tar")
12454        || lower.ends_with(".gz")
12455    {
12456        PreviewKind::Skipped
12457    } else {
12458        PreviewKind::Unsupported
12459    }
12460}
12461
12462fn preview_relative_path(root: &Path, path: &Path) -> String {
12463    path.strip_prefix(root)
12464        .ok()
12465        .unwrap_or(path)
12466        .to_string_lossy()
12467        .replace('\\', "/")
12468        .trim_matches('/')
12469        .to_string()
12470}
12471
12472fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
12473    if relative.is_empty() {
12474        return false;
12475    }
12476
12477    exclude_patterns.iter().any(|pattern| {
12478        wildcard_match(pattern, relative)
12479            || wildcard_match(pattern, &format!("{relative}/"))
12480            || wildcard_match(pattern, &format!("{relative}/placeholder"))
12481    })
12482}
12483
12484fn should_include_preview_file(
12485    relative: &str,
12486    include_patterns: &[String],
12487    exclude_patterns: &[String],
12488) -> bool {
12489    if relative.is_empty() {
12490        return true;
12491    }
12492
12493    let included = include_patterns.is_empty()
12494        || include_patterns
12495            .iter()
12496            .any(|pattern| wildcard_match(pattern, relative));
12497    let excluded = exclude_patterns
12498        .iter()
12499        .any(|pattern| wildcard_match(pattern, relative));
12500
12501    included && !excluded
12502}
12503
12504fn wildcard_match(pattern: &str, candidate: &str) -> bool {
12505    let pattern = pattern.trim().replace('\\', "/");
12506    let candidate = candidate.trim().replace('\\', "/");
12507    let p = pattern.as_bytes();
12508    let c = candidate.as_bytes();
12509    let mut pi = 0usize;
12510    let mut ci = 0usize;
12511    let mut star: Option<usize> = None;
12512    let mut star_match = 0usize;
12513
12514    while ci < c.len() {
12515        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
12516            pi += 1;
12517            ci += 1;
12518        } else if pi < p.len() && p[pi] == b'*' {
12519            while pi < p.len() && p[pi] == b'*' {
12520                pi += 1;
12521            }
12522            star = Some(pi);
12523            star_match = ci;
12524        } else if let Some(star_pi) = star {
12525            star_match += 1;
12526            ci = star_match;
12527            pi = star_pi;
12528        } else {
12529            return false;
12530        }
12531    }
12532
12533    while pi < p.len() && p[pi] == b'*' {
12534        pi += 1;
12535    }
12536
12537    pi == p.len()
12538}
12539
12540fn escape_html(value: &str) -> String {
12541    value
12542        .replace('&', "&amp;")
12543        .replace('<', "&lt;")
12544        .replace('>', "&gt;")
12545        .replace('"', "&quot;")
12546        .replace('\'', "&#39;")
12547}
12548
12549#[derive(Clone)]
12550struct SubmoduleRow {
12551    name: String,
12552    relative_path: String,
12553    files_analyzed: u64,
12554    code_lines: u64,
12555    comment_lines: u64,
12556    blank_lines: u64,
12557    total_physical_lines: u64,
12558    html_url: Option<String>,
12559}
12560
12561#[derive(Template)]
12562#[template(
12563    source = r##"
12564<!doctype html>
12565<html lang="en">
12566<head>
12567  <meta charset="utf-8">
12568  <title>OxideSLOC | tmp-sloc</title>
12569  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12570  <style nonce="{{ csp_nonce }}">
12571    :root {
12572      --bg: #efe9e2;
12573      --surface: #fcfaf7;
12574      --surface-2: #f7f0e8;
12575      --surface-3: #efe3d5;
12576      --line: #dfcfbf;
12577      --line-strong: #cfb29c;
12578      --text: #2f241c;
12579      --muted: #6f6257;
12580      --muted-2: #917f71;
12581      --nav: #b85d33;
12582      --nav-2: #7a371b;
12583      --accent: #2563eb;
12584      --accent-2: #1d4ed8;
12585      --oxide: #b85d33;
12586      --oxide-2: #8f4220;
12587      --success-bg: #eaf9ee;
12588      --success-text: #1c8746;
12589      --warn-bg: #fff2d8;
12590      --warn-text: #926000;
12591      --danger-bg: #fdeaea;
12592      --danger-text: #b33b3b;
12593      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
12594      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
12595      --radius: 14px;
12596    }
12597
12598    body.dark-theme {
12599      --bg: #1b1511;
12600      --surface: #261c17;
12601      --surface-2: #2d221d;
12602      --surface-3: #372922;
12603      --line: #524238;
12604      --line-strong: #6c5649;
12605      --text: #f5ece6;
12606      --muted: #c7b7aa;
12607      --muted-2: #aa9485;
12608      --nav: #b85d33;
12609      --nav-2: #7a371b;
12610      --accent: #6f9bff;
12611      --accent-2: #4a78ee;
12612      --oxide: #d37a4c;
12613      --oxide-2: #b35428;
12614      --success-bg: #163927;
12615      --success-text: #8fe2a8;
12616      --warn-bg: #3c2d11;
12617      --warn-text: #f3cb75;
12618      --danger-bg: #3d1f1f;
12619      --danger-text: #ff9f9f;
12620      --shadow: 0 14px 28px rgba(0,0,0,0.28);
12621      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
12622    }
12623
12624    * { box-sizing: border-box; }
12625    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); }
12626    html { overflow-y: scroll; }
12627    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
12628    .top-nav, .page, .loading { position: relative; z-index: 2; }
12629    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
12630    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
12631    .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); }
12632    .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; }
12633    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
12634    .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)); }
12635    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
12636    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
12637    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
12638    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
12639    .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; }
12640    .nav-project-pill.visible { display:inline-flex; }
12641    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
12642    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
12643    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
12644    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12645    @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; } }
12646    .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; }
12647    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
12648    .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; }
12649    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
12650    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
12651    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
12652    .theme-toggle .icon-sun { display:none; }
12653    body.dark-theme .theme-toggle .icon-sun { display:block; }
12654    body.dark-theme .theme-toggle .icon-moon { display:none; }
12655    .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;}
12656    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12657    .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);}
12658    .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;}
12659    .settings-close:hover{color:var(--text);background:var(--surface-2);}
12660    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12661    .settings-modal-body{padding:14px 16px 16px;}
12662    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12663    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12664    .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;}
12665    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12666    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12667    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12668    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12669    .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;}
12670    .tz-select:focus{border-color:var(--oxide);}
12671    .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; }
12672    .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;}
12673    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
12674    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
12675    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
12676    .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; }
12677    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
12678    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
12679    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
12680    .wb-stats-header { padding: 10px 24px 0; }
12681    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
12682    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
12683    .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; }
12684    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12685    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12686    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12687    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
12688    .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; }
12689    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
12690    .ws-stat-analyzers { position: relative; }
12691    .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; }
12692    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
12693    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
12694    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
12695    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
12696    .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; }
12697    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
12698    .ws-divider { display: none; }
12699    .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%; }
12700    .ws-path-link:hover { color:var(--oxide); }
12701    body.dark-theme .ws-path-link { color:var(--oxide); }
12702    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
12703    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12704    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
12705    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
12706    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
12707    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
12708    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
12709    .ws-mini-box-lg { flex:2 1 0; }
12710    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
12711    .ws-mini-box-br { flex:1.5 1 0; }
12712    .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); }
12713    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
12714    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
12715    #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; }
12716    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
12717    .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; }
12718    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
12719    .git-source-banner strong { font-weight:800; color:var(--text); }
12720    .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; }
12721    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
12722    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
12723    .git-source-banner a:hover { text-decoration:underline; }
12724    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
12725    .path-scope-sep { background:var(--line); margin:4px 14px; }
12726    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
12727    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
12728    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
12729    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
12730    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
12731    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
12732    .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; }
12733    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
12734    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
12735    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
12736    .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; }
12737    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
12738    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
12739    [data-wb-tip] { cursor:help; }
12740    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
12741    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
12742    .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; }
12743    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
12744    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
12745    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
12746    .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; }
12747    .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); }
12748    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
12749    .side-info-card { padding: 18px; }
12750    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
12751    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
12752    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
12753    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
12754    .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); }
12755    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
12756    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12757    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
12758    .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; }
12759    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
12760    .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; }
12761    .side-stack::-webkit-scrollbar { display: none; }
12762    .step-nav { padding: 20px 16px; }
12763    .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); }
12764    .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; }
12765    .step-button:hover { background: var(--surface-2); }
12766    .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); }
12767    .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; }
12768    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
12769    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
12770    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
12771    .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); }
12772    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
12773    .step-nav-sum-row:last-child { border-bottom:none; }
12774    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
12775    .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; }
12776    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
12777    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
12778    .quick-scan-section { padding: 10px 4px 14px; }
12779    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
12780    .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; }
12781    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
12782    .quick-scan-btn:active { transform:translateY(0); }
12783    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
12784    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
12785    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
12786    @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);} }
12787    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
12788    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
12789    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
12790    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
12791    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
12792    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
12793    .step-button.done .step-check { opacity:1; }
12794    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
12795    .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; }
12796    .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; }
12797    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
12798    .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; }
12799    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
12800    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
12801    .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; }
12802    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
12803    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
12804    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
12805    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
12806    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
12807    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
12808    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
12809    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
12810    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
12811    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
12812    .card-body { padding: 22px; }
12813    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
12814    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
12815    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
12816    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
12817    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
12818    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
12819    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
12820    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
12821    .field { min-width:0; }
12822    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
12823    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; }
12824    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); }
12825    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
12826    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); }
12827    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
12828    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
12829    .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; }
12830    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
12831    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
12832    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
12833    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
12834    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
12835    .input-group.compact { grid-template-columns: 1fr auto auto; }
12836    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
12837    .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)); }
12838    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
12839    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
12840    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
12841    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
12842    .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; }
12843    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
12844    .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; }
12845    .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); }
12846    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
12847    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
12848    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
12849    button.secondary { background: var(--surface); }
12850    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
12851    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
12852    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
12853    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
12854    .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); }
12855    .section + .wizard-actions { border-top: none; padding-top: 0; }
12856    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
12857    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
12858    .field-help-grid.coupled-help { margin-top: 12px; }
12859    .field-help-grid.preset-grid { align-items: start; }
12860    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
12861    .preset-inline-row .field { margin: 0; }
12862    .preset-inline-row .explainer-card { margin: 0; }
12863    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
12864    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
12865    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
12866    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
12867    .preset-kv-row > :last-child { flex:1; min-width:0; }
12868    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
12869    .output-field-row .field { margin: 0; }
12870    .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; }
12871    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
12872    .step3-subtitle { margin-bottom: 10px; max-width: none; }
12873    .counting-intro { margin-bottom: 8px; max-width: none; }
12874    .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; }
12875    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
12876    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
12877    .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; }
12878    .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; }
12879    .section-spacer-top { margin-top: 28px; }
12880    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
12881    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
12882    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
12883    .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); }
12884    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
12885    .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; }
12886    .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; }
12887    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
12888    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
12889    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
12890    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
12891    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
12892    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
12893    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
12894    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
12895    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
12896    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
12897    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
12898    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
12899    .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); }
12900    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
12901    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
12902    .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; }
12903    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
12904    .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; }
12905    .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; }
12906    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
12907    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
12908    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
12909    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
12910    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
12911    .advanced-rule-description strong { color: var(--text); }
12912    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
12913    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
12914    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
12915    .review-link:hover { text-decoration: underline; }
12916    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
12917    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
12918    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
12919    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
12920    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
12921    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
12922    .review-card ul { padding-left: 18px; margin: 0; }
12923    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
12924    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
12925    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
12926    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
12927    .review-card { min-height: 0; }
12928    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
12929    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
12930    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
12931    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
12932    .lang-overflow-chip { position:relative; cursor:default; }
12933    .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; }
12934    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
12935    .git-inline-row { align-items:start; }
12936    .mixed-line-card { display:flex; flex-direction:column; }
12937    .preset-inline-row .toggle-card { justify-content: center; }
12938        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
12939    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
12940    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
12941    .explorer-title { font-size: 18px; font-weight: 850; }
12942    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
12943    .explorer-subtitle.wide { max-width: none; }
12944    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
12945    .better-spacing { align-items:flex-start; justify-content:flex-end; }
12946    .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; }
12947    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
12948    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
12949    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
12950    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
12951    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
12952    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
12953    .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; }
12954    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
12955    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
12956    .scope-stat-button.supported { background: var(--success-bg); }
12957    .scope-stat-button.skipped { background: var(--warn-bg); }
12958    .scope-stat-button.unsupported { background: var(--danger-bg); }
12959    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
12960    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
12961    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
12962    [data-tooltip] { position: relative; }
12963    [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); }
12964    [data-tooltip]:hover::after { display: block; }
12965    .scope-stat-button[data-tooltip] { cursor: pointer; }
12966    .badge[data-tooltip] { cursor: help; }
12967    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
12968    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
12969    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
12970    .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; }
12971    .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; }
12972    code { display:inline-block; margin-top:0; padding:2px 7px; }
12973    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
12974    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
12975    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
12976    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
12977    .language-pill.muted-pill { color: var(--muted); }
12978    button.language-pill { appearance:none; cursor:pointer; }
12979    .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); }
12980    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
12981    .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; }
12982    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
12983    .file-explorer-search-row { margin-left: auto; }
12984    .explorer-filter-select { min-width: 170px; width: 170px; }
12985    .explorer-search { min-width: 300px; width: 300px; }
12986    .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); }
12987    .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; }
12988    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
12989    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
12990    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
12991    .file-explorer-tree { max-height: 640px; overflow:auto; }
12992    .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); }
12993    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
12994    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
12995    .tree-row.hidden-by-filter { display:none !important; }
12996    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
12997    .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; }
12998    .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; }
12999    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
13000    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
13001    .tree-node { display:inline-flex; align-items:center; min-width:0; }
13002    .tree-node-dir { color: var(--text); font-weight: 800; }
13003    .tree-node-supported { color: var(--success-text); }
13004    .tree-node-skipped { color: var(--warn-text); }
13005    .tree-node-unsupported { color: var(--danger-text); }
13006    .tree-node-more { color: var(--muted-2); font-style: italic; }
13007    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
13008    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
13009    .tree-status-cell { display:flex; justify-content:flex-start; }
13010    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
13011    .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; }
13012    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
13013    .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; }
13014    @keyframes prevSpin { to { transform:rotate(360deg); } }
13015    .preview-loading-text { flex:1; min-width:0; }
13016    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
13017    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
13018    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
13019    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
13020    .cov-scan-idle { display:none; }
13021    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
13022    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
13023    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
13024    .cov-scan-title { font-weight:600; font-size:12.5px; }
13025    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
13026    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
13027    .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; }
13028    .cov-scan-use:hover { opacity:.75; }
13029    .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; }
13030    .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; }
13031    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
13032    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
13033    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
13034    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
13035    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
13036    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
13037    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
13038    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
13039    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
13040    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
13041    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
13042    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
13043    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
13044    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
13045    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
13046    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
13047    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
13048    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
13049    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
13050    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
13051    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
13052    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
13053    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
13054    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
13055    .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); }
13056    .loading.active { display:flex; }
13057    .loading-card { width: min(730px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 48px rgba(0,0,0,0.22); padding: 36px 42px; }
13058    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
13059    .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; }
13060    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
13061    .lc-badge { display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.28);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:16px; }
13062    .lc-dot { width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:lcPulse 1.4s ease-in-out infinite;flex:0 0 auto; }
13063    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
13064    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
13065    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
13066    .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:16px; }
13067    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
13068    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
13069    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
13070    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
13071    .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; }
13072    .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; }
13073    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
13074    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
13075    .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; }
13076    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
13077    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
13078    .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; }
13079    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
13080    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
13081    .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; }
13082    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
13083    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
13084    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
13085    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
13086    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
13087    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
13088    .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; }
13089    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
13090    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
13091    .hidden { display:none !important; }
13092    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13093    .site-footer a{color:var(--muted);}
13094    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
13095    @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; } }
13096    .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;}
13097    @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));}}
13098    .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;}
13099    .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; }
13100    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
13101    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
13102    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
13103    .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; }
13104    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
13105    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
13106    .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; }
13107    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
13108    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
13109    .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; }
13110    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
13111    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
13112    .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; }
13113    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
13114    .info-icon-btn:hover { color:var(--text); }
13115    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); }
13116    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
13117    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
13118    .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;}
13119    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
13120    .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;}
13121    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
13122    #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);}
13123    #offline-file-banner.show{display:flex;}
13124    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
13125    #offline-file-banner .ofb-text{flex:1;}
13126    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
13127    #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;}
13128    #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;}
13129    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
13130    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
13131    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
13132    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
13133    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
13134    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
13135    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
13136  </style>
13137</head>
13138<body id="page-top">
13139  <div id="offline-file-banner" role="alert">
13140    <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>
13141    <span class="ofb-text">
13142      Charts, images, and navigation require the oxide-sloc server.
13143      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
13144      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
13145      The metric tables below are fully readable without the server.
13146    </span>
13147    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
13148  </div>
13149  <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>
13150  <div class="background-watermarks" aria-hidden="true">
13151    <img src="/images/logo/logo-text.png" alt="" />
13152    <img src="/images/logo/logo-text.png" alt="" />
13153    <img src="/images/logo/logo-text.png" alt="" />
13154    <img src="/images/logo/logo-text.png" alt="" />
13155    <img src="/images/logo/logo-text.png" alt="" />
13156    <img src="/images/logo/logo-text.png" alt="" />
13157    <img src="/images/logo/logo-text.png" alt="" />
13158    <img src="/images/logo/logo-text.png" alt="" />
13159    <img src="/images/logo/logo-text.png" alt="" />
13160    <img src="/images/logo/logo-text.png" alt="" />
13161    <img src="/images/logo/logo-text.png" alt="" />
13162    <img src="/images/logo/logo-text.png" alt="" />
13163    <img src="/images/logo/logo-text.png" alt="" />
13164    <img src="/images/logo/logo-text.png" alt="" />
13165  </div>
13166  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13167  <div class="top-nav">
13168    <div class="top-nav-inner">
13169      <a class="brand" href="/">
13170        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
13171        <div class="brand-copy">
13172          <div class="brand-title">OxideSLOC</div>
13173          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
13174        </div>
13175      </a>
13176      <div class="nav-project-slot">
13177        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
13178          <span class="nav-project-label">Project</span>
13179          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
13180        </div>
13181      </div>
13182      <div class="nav-status">
13183        <a class="nav-pill" href="/">Home</a>
13184        <div class="nav-dropdown">
13185          <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>
13186          <div class="nav-dropdown-menu">
13187            <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>
13188          </div>
13189        </div>
13190        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13191        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13192        <div class="nav-dropdown">
13193          <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>
13194          <div class="nav-dropdown-menu">
13195            <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>
13196          </div>
13197        </div>
13198        <div class="server-status-wrap" id="server-status-wrap">
13199          <div class="nav-pill server-online-pill" id="server-status-pill">
13200            <span class="status-dot" id="status-dot"></span>
13201            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
13202            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13203          </div>
13204          <div class="server-status-tip">
13205            {% if server_mode %}
13206            OxideSLOC is running in server mode — accessible on your LAN.
13207            {% else %}
13208            OxideSLOC is running locally — only accessible from this machine.
13209            {% endif %}
13210            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13211          </div>
13212        </div>
13213        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13214          <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>
13215        </button>
13216        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13217          <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>
13218          <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>
13219        </button>
13220      </div>
13221    </div>
13222  </div>
13223
13224  <div class="loading" id="loading">
13225    <div class="loading-card">
13226      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
13227      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
13228      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
13229      <div class="lc-path" id="lc-path"></div>
13230      <div class="lc-metrics" id="lc-metrics">
13231        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
13232        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
13233        <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>
13234      </div>
13235      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
13236      <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>
13237      <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>
13238      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
13239      <div class="lc-actions hidden" id="lc-actions">
13240        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
13241        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
13242      </div>
13243      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
13244        <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>
13245        Cancel scan
13246      </button>
13247    </div>
13248  </div>
13249
13250  <div class="page">
13251    <div class="workbench-strip">
13252      <div class="workbench-box wb-stats">
13253        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
13254          <span class="wb-stats-title">Analysis session</span>
13255        </div>
13256        <div class="ws-left">
13257          <div class="ws-stat ws-stat-analyzers">
13258            <span class="ws-label">Analyzers</span>
13259            <span class="ws-value">
13260              <span class="ws-badge">41 languages</span>
13261            </span>
13262            <div class="ws-lang-tooltip">
13263              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
13264              <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>
13265              <div class="ws-lang-grid">
13266                <span class="ws-lang-item">Assembly</span>
13267                <span class="ws-lang-item">C</span>
13268                <span class="ws-lang-item">C++</span>
13269                <span class="ws-lang-item">C#</span>
13270                <span class="ws-lang-item">Clojure</span>
13271                <span class="ws-lang-item">CSS</span>
13272                <span class="ws-lang-item">Dart</span>
13273                <span class="ws-lang-item">Dockerfile</span>
13274                <span class="ws-lang-item">Elixir</span>
13275                <span class="ws-lang-item">Erlang</span>
13276                <span class="ws-lang-item">F#</span>
13277                <span class="ws-lang-item">Go</span>
13278                <span class="ws-lang-item">Groovy</span>
13279                <span class="ws-lang-item">Haskell</span>
13280                <span class="ws-lang-item">HTML</span>
13281                <span class="ws-lang-item">Java</span>
13282                <span class="ws-lang-item">JavaScript</span>
13283                <span class="ws-lang-item">Julia</span>
13284                <span class="ws-lang-item">Kotlin</span>
13285                <span class="ws-lang-item">Lua</span>
13286                <span class="ws-lang-item">Makefile</span>
13287                <span class="ws-lang-item">Nim</span>
13288                <span class="ws-lang-item">Obj-C</span>
13289                <span class="ws-lang-item">OCaml</span>
13290                <span class="ws-lang-item">Perl</span>
13291                <span class="ws-lang-item">PHP</span>
13292                <span class="ws-lang-item">PowerShell</span>
13293                <span class="ws-lang-item">Python</span>
13294                <span class="ws-lang-item">R</span>
13295                <span class="ws-lang-item">Ruby</span>
13296                <span class="ws-lang-item">Rust</span>
13297                <span class="ws-lang-item">Scala</span>
13298                <span class="ws-lang-item">SCSS</span>
13299                <span class="ws-lang-item">Shell</span>
13300                <span class="ws-lang-item">SQL</span>
13301                <span class="ws-lang-item">Svelte</span>
13302                <span class="ws-lang-item">Swift</span>
13303                <span class="ws-lang-item">TypeScript</span>
13304                <span class="ws-lang-item">Vue</span>
13305                <span class="ws-lang-item">XML</span>
13306                <span class="ws-lang-item">Zig</span>
13307              </div>
13308            </div>
13309          </div>
13310          <div class="ws-divider"></div>
13311          <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>
13312          <div class="ws-divider"></div>
13313          <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.">
13314            <span class="ws-label">Output</span>
13315            <span class="ws-value">
13316              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
13317                <span id="ws-output-root">project/sloc</span>
13318              </button>
13319            </span>
13320          </div>
13321        </div>
13322      </div>
13323      <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.">
13324        <div class="ws-history-label">Scan history</div>
13325        <div class="ws-history-inner">
13326          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
13327            <div class="ws-mini-label">Scans</div>
13328            <div class="ws-mini-value" id="ws-scan-count">—</div>
13329          </div>
13330          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
13331            <div class="ws-mini-label">Last Scan</div>
13332            <div class="ws-mini-value" id="ws-last-scan">—</div>
13333          </div>
13334          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
13335            <div class="ws-mini-label">Branch</div>
13336            <div class="ws-mini-value" id="ws-branch">—</div>
13337          </div>
13338        </div>
13339      </div>
13340    </div>
13341
13342    <div class="layout">
13343      <aside class="side-stack">
13344        <section class="step-nav">
13345        <h3>Guided scan setup</h3>
13346        <div class="sidebar-scroll-divider"></div>
13347        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
13348          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
13349          Top of page
13350        </a>
13351        <div class="sidebar-scroll-divider"></div>
13352        <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>
13353        <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>
13354        <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>
13355        <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>
13356
13357        <div class="step-steps-divider"></div>
13358
13359        <div class="step-nav-info" id="step-nav-info">
13360          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
13361          <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>
13362        </div>
13363
13364        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
13365          <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>
13366          <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>
13367          <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>
13368        </div>
13369
13370        <div class="quick-scan-divider"></div>
13371        <div class="quick-scan-section">
13372          <div class="quick-scan-label">No customization needed?</div>
13373          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
13374            <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>
13375            Quick Scan
13376          </button>
13377          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
13378        </div>
13379
13380        <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>
13381        <div class="sidebar-scroll-divider"></div>
13382        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
13383          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
13384          Skip to bottom
13385        </a>
13386        </section>
13387
13388      </aside>
13389
13390      <section class="card">
13391        <div class="card-header">
13392          <div class="card-title-row">
13393            <div>
13394              <h1 class="card-title">Guided scan configuration</h1>
13395              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
13396            </div>
13397            <div class="wizard-progress" aria-label="Scan setup progress">
13398              <div class="wizard-progress-top">
13399                <span class="wizard-progress-label">Setup progress</span>
13400                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
13401              </div>
13402              <div class="wizard-progress-track">
13403                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
13404              </div>
13405            </div>
13406          </div>
13407        </div>
13408        <div class="card-body">
13409          <form method="post" action="/analyze" id="analyze-form">
13410            <div class="wizard-step active" data-step="1">
13411              <div class="section">
13412                <div class="section-kicker">Step 1</div>
13413                <h2>Select project and preview scope</h2>
13414                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
13415                <div class="field">
13416                  <label for="path">Project path</label>
13417                  {% if !git_repo.is_empty() %}
13418                  <div class="git-source-banner">
13419                    <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>
13420                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
13421                    <a href="/git-browser">← Back to Git Browser</a>
13422                  </div>
13423                  {% endif %}
13424                  <div class="path-scope-grid">
13425                      {% if !git_repo.is_empty() %}
13426                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
13427                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
13428                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
13429                      {% else %}
13430                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required onblur="this.scrollLeft=this.scrollWidth" />
13431                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
13432                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
13433                      {% endif %}
13434                    <div class="path-scope-sep"></div>
13435                    <div class="scope-legend-row">
13436                      <span class="scope-legend-label">Scope legend:</span>
13437                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
13438                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
13439                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
13440                    </div>
13441                  </div>
13442                  {% if git_repo.is_empty() %}
13443                  {% if server_mode %}
13444                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
13445                    ℹ️ Files are compressed and streamed — no fixed size limit.
13446                  </div>
13447                  {% endif %}
13448                  <div class="path-info-row">
13449                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
13450                      <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>
13451                      <span id="project-size-text">Project size: —</span>
13452                    </button>
13453                  </div>
13454                  {% else %}
13455                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
13456                  {% endif %}
13457                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
13458                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
13459                </div>
13460
13461                <div class="scope-preview-divider" aria-hidden="true"></div>
13462
13463                <div id="preview-panel">
13464                  <div class="preview-error">Loading preview...</div>
13465                </div>
13466              </div>
13467
13468              <div class="section" style="margin-top:14px;">
13469                <div class="preset-inline-row git-inline-row">
13470                  <div class="toggle-card" style="margin:0;">
13471                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
13472                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
13473                    <label class="checkbox">
13474                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
13475                      <div>
13476                        <span>Detect and separate git submodules</span>
13477                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
13478                      </div>
13479                    </label>
13480                  </div>
13481                  <div class="explainer-card prominent" style="margin:0;">
13482                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13483                    <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>
13484                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
13485    path = libs/core
13486    url  = https://github.com/org/core.git
13487
13488[submodule "libs/ui"]
13489    path = libs/ui
13490    url  = https://github.com/org/ui.git</div>
13491                  </div>
13492                </div>
13493              </div>
13494
13495              <div class="section">
13496                <div class="field-grid">
13497                  <div class="field">
13498                    <label for="include_globs">Include globs</label>
13499                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
13500                    <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>
13501                  </div>
13502                  <div class="field">
13503                    <label for="exclude_globs">Exclude globs</label>
13504                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
13505                    <div id="quick-exclude-chips" class="quick-excl-row">
13506                      <span class="quick-excl-label">Quick add:</span>
13507                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
13508                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
13509                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
13510                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
13511                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
13512                      <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>
13513                    </div>
13514                    <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>
13515                  </div>
13516                </div>
13517                <div class="glob-guidance-grid">
13518                  <div class="glob-guidance-card">
13519                    <strong>How to read them</strong>
13520                    <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>
13521                  </div>
13522                  <div class="glob-guidance-card">
13523                    <strong>Common include examples</strong>
13524                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
13525                  </div>
13526                  <div class="glob-guidance-card">
13527                    <strong>Common exclude examples</strong>
13528                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
13529                  </div>
13530                </div>
13531              </div>
13532
13533              <div class="section" style="margin-top:14px;">
13534                <div class="preset-inline-row git-inline-row">
13535                  <div class="toggle-card" style="margin:0;">
13536                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
13537                    <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>
13538                    <div class="field" style="margin:0;">
13539                      <div class="input-group compact">
13540                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
13541                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
13542                      </div>
13543                      <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>
13544                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
13545                    </div>
13546                  </div>
13547                  <div class="explainer-card prominent" style="margin:0;">
13548                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
13549                    <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>
13550                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
13551lcov --capture --directory . --output-file coverage/lcov.info
13552
13553# C / C++ — llvm-cov (LCOV)
13554llvm-profdata merge -sparse default.profraw -o default.profdata
13555llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
13556
13557# C# — coverlet (Cobertura XML)
13558dotnet test --collect:"XPlat Code Coverage"
13559
13560# Python — pytest-cov (Cobertura XML)
13561pytest --cov --cov-report=xml
13562
13563# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
13564./gradlew jacocoTestReport</div>
13565                  </div>
13566                </div>
13567              </div>
13568
13569              <div class="wizard-actions">
13570                <div class="left"></div>
13571                <div class="right">
13572                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
13573                </div>
13574              </div>
13575            </div>
13576
13577            <div class="wizard-step" data-step="2">
13578              <div class="section">
13579                <div class="section-kicker">Step 2</div>
13580                <h2>Choose counting behavior</h2>
13581                <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>
13582<div class="subsection-bar">Primary line classification</div>
13583                <div class="preset-kv-row">
13584                  <div class="toggle-card mixed-line-card" style="margin:0;">
13585                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
13586                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
13587                    <select id="mixed_line_policy" name="mixed_line_policy">
13588                      <option value="code_only">Code only</option>
13589                      <option value="code_and_comment">Code and comment</option>
13590                      <option value="comment_only">Comment only</option>
13591                      <option value="separate_mixed_category">Separate mixed category</option>
13592                    </select>
13593                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
13594                  </div>
13595                  <div class="explainer-card prominent" style="margin:0;">
13596                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
13597                    <div class="explainer-body" id="mixed-policy-description"></div>
13598                    <div class="code-sample" id="mixed-policy-example"></div>
13599                  </div>
13600                </div>
13601              </div>
13602
13603              <div class="subsection-bar">Additional scan rules</div>
13604              <div class="scan-rules-grid">
13605                <div class="preset-inline-row">
13606                  <div class="toggle-card" style="margin:0;">
13607                    <div class="field-help-title">Generated files</div>
13608                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
13609                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13610                  </div>
13611                  <div class="explainer-card prominent" style="margin:0;">
13612                    <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>
13613                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
13614# Files matching codegen patterns are excluded:
13615#   *.generated.cs  *.pb.go  *.g.dart</div>
13616                  </div>
13617                </div>
13618                <div class="preset-inline-row">
13619                  <div class="toggle-card" style="margin:0;">
13620                    <div class="field-help-title">Minified files</div>
13621                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
13622                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13623                  </div>
13624                  <div class="explainer-card prominent" style="margin:0;">
13625                    <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>
13626                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
13627# Heuristic: very long lines + low whitespace ratio
13628#   jquery.min.js  bundle.min.css  → skipped</div>
13629                  </div>
13630                </div>
13631                <div class="preset-inline-row">
13632                  <div class="toggle-card" style="margin:0;">
13633                    <div class="field-help-title">Vendor directories</div>
13634                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
13635                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
13636                  </div>
13637                  <div class="explainer-card prominent" style="margin:0;">
13638                    <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>
13639                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
13640# Directories named vendor/ node_modules/ third_party/
13641#   → entire subtree is excluded from totals</div>
13642                  </div>
13643                </div>
13644                <div class="preset-inline-row">
13645                  <div class="toggle-card" style="margin:0;">
13646                    <div class="field-help-title">Lockfiles and manifests</div>
13647                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
13648                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
13649                  </div>
13650                  <div class="explainer-card prominent" style="margin:0;">
13651                    <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>
13652                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
13653# Files like package-lock.json  Cargo.lock  yarn.lock
13654#   → skipped unless this is enabled</div>
13655                  </div>
13656                </div>
13657                <div class="preset-inline-row">
13658                  <div class="toggle-card" style="margin:0;">
13659                    <div class="field-help-title">Binary handling</div>
13660                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
13661                    <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>
13662                  </div>
13663                  <div class="explainer-card prominent" style="margin:0;">
13664                    <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>
13665                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
13666# Detected via long lines + low whitespace heuristic
13667#   .png  .exe  .so  → skipped silently</div>
13668                  </div>
13669                </div>
13670                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
13671                  <div class="toggle-card" style="margin:0;">
13672                    <div class="field-help-title">Python docstrings</div>
13673                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
13674                    <label class="checkbox">
13675                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
13676                      <span>Count as comment-style lines</span>
13677                    </label>
13678                  </div>
13679                  <div class="explainer-card prominent" style="margin:0;">
13680                    <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>
13681                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
13682                  </div>
13683                </div>
13684              </div>
13685              <div class="subsection-bar">IEEE 1045-1992 counting</div>
13686              <div class="scan-rules-grid">
13687                <div class="preset-inline-row">
13688                  <div class="toggle-card" style="margin:0;">
13689                    <div class="field-help-title">Continuation lines</div>
13690                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
13691                    <select name="continuation_line_policy" id="continuation_line_policy">
13692                      <option value="each_physical_line" selected>Each physical line (default)</option>
13693                      <option value="collapse_to_logical">Collapse to logical line</option>
13694                    </select>
13695                  </div>
13696                  <div class="explainer-card prominent" style="margin:0;">
13697                    <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>
13698                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
13699    ((a) &gt; (b) ? (a) : (b))
13700# each_physical_line → 2 SLOC
13701# collapse_to_logical → 1 SLOC</div>
13702                  </div>
13703                </div>
13704                <div class="preset-inline-row">
13705                  <div class="toggle-card" style="margin:0;">
13706                    <div class="field-help-title">Block-comment blanks</div>
13707                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
13708                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
13709                      <option value="count_as_comment" selected>Count as comment (default)</option>
13710                      <option value="count_as_blank">Count as blank</option>
13711                    </select>
13712                  </div>
13713                  <div class="explainer-card prominent" style="margin:0;">
13714                    <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>
13715                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
13716 * Summary line
13717 *              ← blank inside block comment
13718 * Detail line
13719 */
13720# count_as_comment → blank counts toward comments
13721# count_as_blank   → blank counts toward blanks</div>
13722                  </div>
13723                </div>
13724                <div class="preset-inline-row">
13725                  <div class="toggle-card" style="margin:0;">
13726                    <div class="field-help-title">Compiler directives</div>
13727                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
13728                    <select name="count_compiler_directives" id="count_compiler_directives">
13729                      <option value="enabled" selected>Include in code SLOC (default)</option>
13730                      <option value="disabled">Exclude from code SLOC</option>
13731                    </select>
13732                  </div>
13733                  <div class="explainer-card prominent" style="margin:0;">
13734                    <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>
13735                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
13736#define BUF 256     ← compiler directive
13737int main() { … }   ← code
13738# enabled  → 3 code SLOC
13739# disabled → 1 code SLOC + 2 directive lines</div>
13740                  </div>
13741                </div>
13742              </div>
13743
13744              <div class="subsection-bar">Code Style Analysis</div>
13745              <div class="scan-rules-grid">
13746                <div class="preset-inline-row">
13747                  <div class="toggle-card" style="margin:0;">
13748                    <div class="field-help-title">Style analysis</div>
13749                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
13750                    <select name="style_analysis_enabled" id="style_analysis_enabled">
13751                      <option value="enabled" selected>Enabled (default)</option>
13752                      <option value="disabled">Disabled — skip style scoring</option>
13753                    </select>
13754                  </div>
13755                  <div class="explainer-card prominent" style="margin:0;">
13756                    <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>
13757                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
13758# style_analysis_enabled = false  (skip, faster scan)
13759# Disabling removes the Code Style section from the report.</div>
13760                  </div>
13761                </div>
13762                <div class="preset-inline-row">
13763                  <div class="toggle-card" style="margin:0;">
13764                    <div class="field-help-title">Column-width threshold</div>
13765                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
13766                    <select name="style_col_threshold" id="style_col_threshold">
13767                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
13768                      <option value="100">100 columns (Uber Go, Google Java)</option>
13769                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
13770                    </select>
13771                  </div>
13772                  <div class="explainer-card prominent" style="margin:0;">
13773                    <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>
13774                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
13775# style_col_threshold = 100 (Uber Go, Google Java)
13776# style_col_threshold = 120 (Uber Go max, Kotlin)
13777# Files where &lt;= 5% of lines exceed the limit
13778# are counted as "N-col compliant" in the report.</div>
13779                  </div>
13780                </div>
13781                <div class="preset-inline-row">
13782                  <div class="toggle-card" style="margin:0;">
13783                    <div class="field-help-title">Score alert threshold</div>
13784                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
13785                    <select name="style_score_threshold" id="style_score_threshold">
13786                      <option value="0" selected>Off — no threshold (default)</option>
13787                      <option value="40">40% — flag poorly styled files</option>
13788                      <option value="50">50% — flag below-average files</option>
13789                      <option value="60">60% — flag below-good files</option>
13790                      <option value="70">70% — flag below-strong files</option>
13791                    </select>
13792                  </div>
13793                  <div class="explainer-card prominent" style="margin:0;">
13794                    <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>
13795                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
13796# style_score_threshold = 50  (flag files &lt; 50%)
13797# Low-scoring files get a red left-border in the
13798# per-file style breakdown table.</div>
13799                  </div>
13800                </div>
13801              </div>
13802
13803              <div class="always-tracked-tip">
13804                <div class="always-tracked-tip-icon">ℹ</div>
13805                <div class="always-tracked-tip-body">
13806                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
13807                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
13808                  <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>
13809                </div>
13810              </div>
13811
13812              <div class="wizard-actions">
13813                <div class="left">
13814                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
13815                </div>
13816                <div class="right">
13817                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
13818                </div>
13819              </div>
13820            </div>
13821
13822            <div class="wizard-step" data-step="3">
13823              <div class="section">
13824                <div class="section-kicker">Step 3</div>
13825                <h2>Output and report identity</h2>
13826                <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>
13827                <div class="preset-kv-row">
13828                  <div class="toggle-card" style="margin:0;">
13829                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
13830                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
13831                    <select id="scan_preset">
13832                      <option value="balanced">Balanced local scan</option>
13833                      <option value="code_focused">Code focused</option>
13834                      <option value="comment_audit">Comment audit</option>
13835                      <option value="deep_review">Deep review</option>
13836                    </select>
13837                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
13838                  </div>
13839                  <div class="explainer-card">
13840                    <div class="field-help-title">Selected scan preset</div>
13841                    <div class="explainer-body" id="scan-preset-description"></div>
13842                    <div class="preset-summary-row" id="scan-preset-summary"></div>
13843                    <div class="code-sample" id="scan-preset-example"></div>
13844                    <div class="preset-note" id="scan-preset-note"></div>
13845                  </div>
13846                </div>
13847                <hr class="step3-separator" />
13848                <div class="preset-kv-row">
13849                  <div class="toggle-card" style="margin:0;">
13850                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
13851                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
13852                    <select id="artifact_preset">
13853                      <option value="review">Review bundle</option>
13854                      <option value="full">Full bundle</option>
13855                      <option value="html_only">HTML only</option>
13856                      <option value="machine">Machine bundle</option>
13857                    </select>
13858                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
13859                  </div>
13860                  <div class="explainer-card">
13861                    <div class="field-help-title">Selected artifact preset</div>
13862                    <div class="explainer-body" id="artifact-preset-description"></div>
13863                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
13864                    <div class="code-sample" id="artifact-preset-example"></div>
13865                  </div>
13866                </div>
13867              </div>
13868
13869              <div class="section section-spacer-top">
13870                <div class="output-field-row">
13871                  <div class="field">
13872                    <label for="output_dir">Output directory</label>
13873                    {% if server_mode %}
13874                    <div class="input-group compact">
13875                      <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);" />
13876                    </div>
13877                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
13878                    {% else %}
13879                    <div class="input-group compact">
13880                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" onblur="this.scrollLeft=this.scrollWidth" />
13881                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
13882                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
13883                    </div>
13884                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
13885                    {% endif %}
13886                  </div>
13887                  <div class="output-field-aside">
13888                    <strong>Where reports land</strong>
13889                    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.
13890                  </div>
13891                </div>
13892              </div>
13893
13894              <div class="section section-spacer-top">
13895                <div class="output-field-row">
13896                  <div class="field">
13897                    <label for="report_title">Report title</label>
13898                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
13899                    <div class="hint">Appears in HTML and PDF output headers.</div>
13900                  </div>
13901                  <div class="output-field-aside">
13902                    <strong>Shown in exported artifacts</strong>
13903                    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.
13904                  </div>
13905                </div>
13906              </div>
13907
13908              <div class="section section-spacer-top">
13909                <div class="output-field-row">
13910                  <div class="field">
13911                    <label for="report_header_footer">Report header / footer</label>
13912                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
13913                    <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>
13914                  </div>
13915                  <div class="output-field-aside">
13916                    <strong>Page-level identification</strong>
13917                    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.
13918                  </div>
13919                </div>
13920              </div>
13921
13922              <div class="wizard-actions">
13923                <div class="left">
13924                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
13925                </div>
13926                <div class="right">
13927                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
13928                </div>
13929              </div>
13930            </div>
13931
13932            <div class="wizard-step" data-step="4">
13933              <div class="section">
13934                <div class="section-kicker">Step 4</div>
13935                <h2>Review selections and run</h2>
13936                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
13937                <div class="review-grid">
13938                  <div class="review-card highlight">
13939                    <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>
13940                    <ul id="review-scan-summary"></ul>
13941                  </div>
13942                  <div class="review-card highlight">
13943                    <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>
13944                    <ul id="review-count-summary"></ul>
13945                  </div>
13946                  <div class="review-card">
13947                    <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>
13948                    <ul id="review-artifact-summary"></ul>
13949                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
13950                  </div>
13951                  <div class="review-card">
13952                    <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>
13953                    <ul id="review-preview-summary"></ul>
13954                  </div>
13955                </div>
13956              </div>
13957
13958              <div class="wizard-actions">
13959                <div class="left">
13960                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
13961                </div>
13962                <div class="right">
13963                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
13964                </div>
13965              </div>
13966            </div>
13967            {% if server_mode %}
13968            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
13969            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
13970            {% endif %}
13971          </form>
13972        </div>
13973      </section>
13974    </div>
13975  </div>
13976
13977  <script nonce="{{ csp_nonce }}">
13978    (function () {
13979      function startScanPhase() {
13980        var phaseEl = document.getElementById("scan-phase");
13981        if (!phaseEl) return;
13982        var phases = [
13983          "Discovering files...",
13984          "Decoding file encodings...",
13985          "Detecting languages...",
13986          "Analyzing source lines...",
13987          "Applying counting policies...",
13988          "Aggregating results...",
13989          "Rendering report..."
13990        ];
13991        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
13992        var i = 0;
13993        function next() {
13994          phaseEl.style.opacity = "0";
13995          setTimeout(function () {
13996            phaseEl.textContent = phases[i];
13997            phaseEl.style.opacity = "0.85";
13998            var delay = durations[i] || 1800;
13999            i++;
14000            if (i < phases.length) { setTimeout(next, delay); }
14001          }, 200);
14002        }
14003        next();
14004      }
14005
14006      var form = document.getElementById("analyze-form");
14007      var loading = document.getElementById("loading");
14008      var submitButton = document.getElementById("submit-button");
14009      var pathInput = document.getElementById("path");
14010      var GIT_MODE = !!(pathInput && pathInput.readOnly);
14011      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
14012      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
14013      var outputDirInput = document.getElementById("output_dir");
14014      var reportTitleInput = document.getElementById("report_title");
14015      var previewPanel = document.getElementById("preview-panel");
14016      var refreshButton = document.getElementById("refresh-preview");
14017      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
14018      var useSamplePath = document.getElementById("use-sample-path");
14019      var useDefaultOutput = document.getElementById("use-default-output");
14020      var browsePath = document.getElementById("browse-path");
14021      var browseOutputDir = document.getElementById("browse-output-dir");
14022      var browseCoverage = document.getElementById("browse-coverage");
14023      var coverageInput = document.getElementById("coverage_file");
14024      var covScanStatus = document.getElementById("cov-scan-status");
14025      var coverageSuggestTimer = null;
14026      var covAutoFilled = false;
14027      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
14028      function fmtBytes(b) {
14029        b = Number(b) || 0;
14030        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
14031        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
14032        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
14033        return b + ' B';
14034      }
14035      var themeToggle = document.getElementById("theme-toggle");
14036
14037      function showBannerToast(msg, isError, opts) {
14038        opts = opts || {};
14039        var t = document.createElement('div');
14040        t.className = isError ? 'toast-error' : 'toast-success';
14041        var topPos = opts.top ? '80px' : null;
14042        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
14043          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
14044          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
14045          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
14046        if (opts.icon) {
14047          var inner = document.createElement('span');
14048          inner.innerHTML = opts.icon + ' ';
14049          t.appendChild(inner);
14050        }
14051        t.appendChild(document.createTextNode(msg));
14052        document.body.appendChild(t);
14053        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
14054      }
14055      var mixedLinePolicy = document.getElementById("mixed_line_policy");
14056      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
14057      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
14058      var scanPreset = document.getElementById("scan_preset");
14059      var artifactPreset = document.getElementById("artifact_preset");
14060      var includeGlobsInput = document.getElementById("include_globs");
14061      var excludeGlobsInput = document.getElementById("exclude_globs");
14062
14063      // Quick-exclude chips — append pattern to exclude_globs textarea.
14064      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
14065        chip.addEventListener("click", function() {
14066          var pattern = chip.getAttribute("data-pattern") || "";
14067          if (!pattern || !excludeGlobsInput) return;
14068          var current = excludeGlobsInput.value.trim();
14069          // For the "skip all" chip, replace any existing dep patterns cleanly.
14070          var patterns = pattern.split("\n");
14071          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
14072          var added = false;
14073          patterns.forEach(function(p) {
14074            p = p.trim();
14075            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
14076          });
14077          if (added) {
14078            excludeGlobsInput.value = lines.join("\n");
14079            excludeGlobsInput.dispatchEvent(new Event("input"));
14080          }
14081          chip.classList.add("active");
14082        });
14083      });
14084
14085      var liveReportTitle = document.getElementById("live-report-title");
14086      var navProjectPill = document.getElementById("nav-project-pill");
14087      var navProjectTitle = document.getElementById("nav-project-title");
14088      var reportTitlePreview = null;
14089      var wizardProgressFill = document.getElementById("wizard-progress-fill");
14090      var wizardProgressValue = document.getElementById("wizard-progress-value");
14091      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
14092      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
14093      var reportTitleTouched = false;
14094      var currentStep = 1;
14095      var previewTimer = null;
14096      var _previewGen = 0;
14097      var quickScanBtn = document.getElementById("quick-scan-btn");
14098
14099      function dismissAnalysisModal() {
14100        if (loading) loading.classList.remove("active");
14101        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14102          var el = document.getElementById(id);
14103          if (el) el.classList.add("hidden");
14104        });
14105        var cancelBtn = document.getElementById("lc-cancel-btn");
14106        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
14107        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
14108        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
14109        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14110        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14111        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14112        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14113        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14114      }
14115
14116      var lcDismissBtn = document.getElementById("lc-dismiss");
14117      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
14118
14119      function startAsyncAnalysis(formData) {
14120        var gitRepo = (formData.get("git_repo") || "").toString();
14121        var gitRef  = (formData.get("git_ref")  || "").toString();
14122        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
14123        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
14124
14125        var pathEl = document.getElementById("lc-path");
14126        if (pathEl) pathEl.textContent = displayPath;
14127
14128        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
14129          var el = document.getElementById(id);
14130          if (el) el.classList.add("hidden");
14131        });
14132        var cancelBtn = document.getElementById("lc-cancel-btn");
14133        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
14134        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
14135        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
14136        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
14137        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
14138        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
14139
14140        if (loading) loading.classList.add("active");
14141
14142        var startTime = Date.now();
14143        var elapsedTimer = setInterval(function() {
14144          var s = Math.floor((Date.now() - startTime) / 1000);
14145          var el = document.getElementById("lc-elapsed");
14146          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
14147        }, 1000);
14148
14149        var warnShown = false, pollRetries = 0, activeWaitId = null;
14150
14151        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();}
14152
14153        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
14154
14155        function lcShowCancelled() {
14156          clearInterval(elapsedTimer);
14157          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
14158          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
14159          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
14160          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
14161          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
14162          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
14163          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
14164          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
14165          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14166          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14167        }
14168
14169        var lcCancelBtn = document.getElementById("lc-cancel-btn");
14170        if (lcCancelBtn) {
14171          lcCancelBtn.onclick = function() {
14172            if (!activeWaitId) { dismissAnalysisModal(); return; }
14173            lcCancelBtn.disabled = true;
14174            lcCancelBtn.textContent = "Cancelling…";
14175            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
14176              .then(function() { lcShowCancelled(); })
14177              .catch(function() { lcShowCancelled(); });
14178          };
14179        }
14180
14181        function lcShowError(msg) {
14182          clearInterval(elapsedTimer);
14183          lcSetPhase("Failed");
14184          var msgEl = document.getElementById("lc-err-msg");
14185          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
14186          var errEl = document.getElementById("lc-err");
14187          var actEl = document.getElementById("lc-actions");
14188          if (errEl) errEl.classList.remove("hidden");
14189          if (actEl) actEl.classList.remove("hidden");
14190          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
14191          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
14192        }
14193
14194        function lcPoll(waitId) {
14195          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
14196            .then(function(r) {
14197              if (!r.ok) throw new Error("HTTP " + r.status);
14198              return r.json();
14199            })
14200            .then(function(data) {
14201              pollRetries = 0;
14202              if (data.state === "complete") {
14203                clearInterval(elapsedTimer);
14204                lcSetPhase("Done");
14205                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
14206              } else if (data.state === "failed") {
14207                lcShowError(data.message);
14208              } else if (data.state === "cancelled") {
14209                lcShowCancelled();
14210              } else {
14211                var s = Math.floor((Date.now() - startTime) / 1000);
14212                if (s > 90 && !warnShown) {
14213                  warnShown = true;
14214                  var w = document.getElementById("lc-warn");
14215                  if (w) w.classList.remove("hidden");
14216                }
14217                lcSetPhase(data.phase || "Running");
14218                var fd = data.files_done || 0, ft = data.files_total || 0;
14219                if (ft > 0) {
14220                  var card = document.getElementById("lc-files-card");
14221                  if (card) card.classList.remove("hidden");
14222                  var el = document.getElementById("lc-files");
14223                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
14224                }
14225                setTimeout(function() { lcPoll(waitId); }, 1500);
14226              }
14227            })
14228            .catch(function() {
14229              pollRetries++;
14230              if (pollRetries >= 5) {
14231                lcShowError("Lost connection to server. Reload to check status.");
14232              } else {
14233                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
14234              }
14235            });
14236        }
14237
14238        var params = new URLSearchParams(formData);
14239        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
14240          .then(function(r) {
14241            var waitId = r.headers.get("x-wait-id");
14242            if (!waitId) { window.location.href = "/scan"; return; }
14243            activeWaitId = waitId;
14244            setTimeout(function() { lcPoll(waitId); }, 1500);
14245          })
14246          .catch(function(err) {
14247            lcShowError("Could not reach server: " + (err.message || err));
14248          });
14249      }
14250
14251      if (quickScanBtn) {
14252        quickScanBtn.addEventListener("click", function () {
14253          var pathVal = pathInput ? pathInput.value.trim() : "";
14254          if (!pathVal) {
14255            alert("Please enter or browse to a project path first.");
14256            return;
14257          }
14258          quickScanBtn.disabled = true;
14259          quickScanBtn.textContent = "Scanning...";
14260          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
14261          startAsyncAnalysis(new FormData(form));
14262        });
14263      }
14264
14265      var mixedPolicyInfo = {
14266        code_only: {
14267          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.",
14268          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'
14269        },
14270        code_and_comment: {
14271          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.",
14272          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'
14273        },
14274        comment_only: {
14275          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.",
14276          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'
14277        },
14278        separate_mixed_category: {
14279          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.",
14280          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'
14281        }
14282      };
14283
14284      var scanPresetInfo = {
14285        balanced: {
14286          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.",
14287          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
14288          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
14289          note: "Best when you want a stable local overview before making deeper adjustments.",
14290          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14291        },
14292        code_focused: {
14293          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
14294          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
14295          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
14296          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
14297          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14298        },
14299        comment_audit: {
14300          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
14301          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
14302          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
14303          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
14304          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
14305        },
14306        deep_review: {
14307          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
14308          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
14309          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
14310          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
14311          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
14312        }
14313      };
14314
14315      var artifactPresetInfo = {
14316        review: {
14317          description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
14318          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
14319          example: "Ideal for a quick local review before sharing results."
14320        },
14321        full: {
14322          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
14323          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
14324          example: "Use when producing a deliverable or storing a snapshot for future comparison."
14325        },
14326        html_only: {
14327          description: "Standalone HTML report only. No PDF generation, no data files.",
14328          chips: ["HTML only"],
14329          example: "Fastest option when you only need to open the report in a browser."
14330        },
14331        machine: {
14332          description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
14333          chips: ["JSON", "CSV", "no HTML", "no PDF"],
14334          example: "Use in CI to capture metrics without generating visual reports."
14335        }
14336      };
14337
14338      function applyArtifactPreset() {
14339        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
14340        if (!info) return;
14341        var descEl = document.getElementById("artifact-preset-description");
14342        var exampleEl = document.getElementById("artifact-preset-example");
14343        if (descEl) descEl.textContent = info.description;
14344        if (exampleEl) exampleEl.textContent = info.example;
14345        renderPresetChips("artifact-preset-summary", info.chips);
14346      }
14347
14348      function applyTheme(theme) {
14349        if (theme === "dark") document.body.classList.add("dark-theme");
14350        else document.body.classList.remove("dark-theme");
14351      }
14352
14353      function loadSavedTheme() {
14354        var saved = null;
14355        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
14356        applyTheme(saved === "dark" ? "dark" : "light");
14357      }
14358
14359      function updateScrollProgress() {
14360        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
14361        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
14362        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
14363        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
14364        var step = Math.min(Math.max(currentStep, 1), 4);
14365        var base = stepBase[step];
14366        var end  = stepEnd[step];
14367
14368        var scrollFrac = 0;
14369        var activePanel = document.querySelector(".wizard-step.active");
14370        if (activePanel) {
14371          var scrollTop = window.scrollY || window.pageYOffset || 0;
14372          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
14373          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
14374          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
14375          var scrolled = scrollTop + viewH - panelTop;
14376          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
14377        }
14378
14379        var percent = Math.round(base + (end - base) * scrollFrac);
14380        percent = Math.min(end, Math.max(base, percent));
14381        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
14382        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
14383      }
14384
14385      function updateWizardProgress() {
14386        updateScrollProgress();
14387      }
14388
14389      var stepDescriptions = [
14390        "Choose a project folder, apply scope filters, and preview which files will be counted.",
14391        "Configure how mixed code-plus-comment lines and docstrings are classified.",
14392        "Pick your output formats, scan preset, and where reports are saved.",
14393        "Review all settings and launch the analysis."
14394      ];
14395
14396      function updateStepNav(step) {
14397        var infoLabel = document.getElementById("step-nav-info-label");
14398        var infoDesc  = document.getElementById("step-nav-info-desc");
14399        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
14400        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
14401      }
14402
14403      function updateSidebarSummary() {
14404        var sumPath    = document.getElementById("sum-path");
14405        var sumPreset  = document.getElementById("sum-preset");
14406        var sumOutput  = document.getElementById("sum-output");
14407        var sidebarSummary = document.getElementById("sidebar-summary");
14408        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
14409        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
14410        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
14411        if (sumPath)   sumPath.textContent   = pathVal   || "—";
14412        if (sumPreset) sumPreset.textContent = presetVal || "—";
14413        if (sumOutput) sumOutput.textContent = outputVal || "—";
14414        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
14415      }
14416
14417      function setStep(step, pushHistory) {
14418        currentStep = step;
14419        stepPanels.forEach(function (panel) {
14420          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
14421        });
14422        stepButtons.forEach(function (button) {
14423          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
14424        });
14425        var layoutEl = document.querySelector(".layout");
14426        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
14427        updateWizardProgress();
14428        updateStepNav(step);
14429        stepButtons.forEach(function(btn) {
14430          var t = Number(btn.getAttribute("data-step-target"));
14431          btn.classList.toggle("done", t < step);
14432        });
14433        updateSidebarSummary();
14434
14435        if (pushHistory !== false) {
14436          try {
14437            history.pushState({ wizardStep: step }, "", "#step" + step);
14438          } catch (e) {}
14439        }
14440
14441        window.scrollTo({ top: 0, behavior: "instant" });
14442      }
14443
14444      window.addEventListener("popstate", function (e) {
14445        if (e.state && e.state.wizardStep) {
14446          setStep(e.state.wizardStep, false);
14447        } else {
14448          var hashMatch = location.hash.match(/^#step([1-4])$/);
14449          if (hashMatch) setStep(Number(hashMatch[1]), false);
14450        }
14451      });
14452
14453      function inferTitleFromPath(value) {
14454        if (!value) return "project";
14455        var cleaned = value.replace(/[\/\\]+$/, "");
14456        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
14457        return parts.length ? parts[parts.length - 1] : value;
14458      }
14459
14460      function updateReportTitleFromPath() {
14461        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
14462        if (!reportTitleTouched) {
14463          reportTitleInput.value = inferred;
14464        }
14465        var title = reportTitleInput.value || inferred;
14466        if (liveReportTitle) liveReportTitle.textContent = title;
14467        if (reportTitlePreview) reportTitlePreview.textContent = title;
14468        document.title = "OxideSLOC | " + title;
14469
14470        var projectPath = (pathInput.value || "").trim();
14471        if (navProjectPill && navProjectTitle) {
14472          if (projectPath.length > 0) {
14473            navProjectTitle.textContent = inferred;
14474            navProjectPill.classList.add("visible");
14475          } else {
14476            navProjectTitle.textContent = "";
14477            navProjectPill.classList.remove("visible");
14478          }
14479        }
14480      }
14481
14482      function updateMixedPolicyUI() {
14483        var key = mixedLinePolicy.value || "code_only";
14484        var info = mixedPolicyInfo[key];
14485        document.getElementById("mixed-policy-description").textContent = info.description;
14486        document.getElementById("mixed-policy-example").textContent = info.example;
14487      }
14488
14489      function updatePythonDocstringUI() {
14490        var checked = !!pythonDocstrings.checked;
14491        document.getElementById("python-docstring-example").textContent = checked
14492          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
14493          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
14494        document.getElementById("python-docstring-live-help").textContent = checked
14495          ? "Enabled: docstrings contribute to comment-style totals."
14496          : "Disabled: docstrings are not counted as comment content.";
14497      }
14498
14499      function renderPresetChips(targetId, chips) {
14500        var target = document.getElementById(targetId);
14501        if (!target) return;
14502        target.innerHTML = (chips || []).map(function (chip) {
14503          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
14504        }).join('');
14505      }
14506
14507      function updatePresetDescriptions() {
14508        var scanInfo = scanPresetInfo[scanPreset.value];
14509        if (!scanInfo) return;
14510        document.getElementById("scan-preset-description").textContent = scanInfo.description;
14511        document.getElementById("scan-preset-example").textContent = scanInfo.example;
14512        document.getElementById("scan-preset-note").textContent = scanInfo.note;
14513        renderPresetChips("scan-preset-summary", scanInfo.chips);
14514      }
14515
14516      function applyScanPreset() {
14517        var info = scanPresetInfo[scanPreset.value];
14518        if (!info || !info.apply) return;
14519        mixedLinePolicy.value = info.apply.mixed;
14520        pythonDocstrings.checked = !!info.apply.docstrings;
14521        document.getElementById("generated_file_detection").value = info.apply.generated;
14522        document.getElementById("minified_file_detection").value = info.apply.minified;
14523        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
14524        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
14525        document.getElementById("binary_file_behavior").value = info.apply.binary;
14526        updateMixedPolicyUI();
14527        updatePythonDocstringUI();
14528      }
14529
14530      function updateReview() {
14531        var scanSummary = document.getElementById("review-scan-summary");
14532        var countSummary = document.getElementById("review-count-summary");
14533        var artifactSummary = document.getElementById("review-artifact-summary");
14534        var outputSummary = document.getElementById("review-output-summary");
14535        var previewSummary = document.getElementById("review-preview-summary");
14536        var readinessSummary = document.getElementById("review-readiness-summary");
14537        var includeText = document.getElementById("include_globs").value.trim();
14538        var excludeText = document.getElementById("exclude_globs").value.trim();
14539        var sidePathPreview = document.getElementById("side-path-preview");
14540        var sideOutputPreview = document.getElementById("side-output-preview");
14541        var sideTitlePreview = document.getElementById("side-title-preview");
14542
14543        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
14544        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
14545        if (sideTitlePreview) {
14546          var rt = document.getElementById("report_title");
14547          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
14548        }
14549
14550        scanSummary.innerHTML = ""
14551          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
14552          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
14553          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
14554
14555        countSummary.innerHTML = ""
14556          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
14557          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
14558          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
14559          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
14560          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
14561          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
14562          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
14563          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
14564
14565        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
14566
14567        outputSummary.innerHTML = ""
14568          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
14569          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
14570
14571        if (previewSummary) {
14572          if (GIT_MODE) {
14573            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>';
14574          } else {
14575          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
14576          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
14577          var statMap = {};
14578          statButtons.forEach(function (button) {
14579            var valueNode = button.querySelector('.scope-stat-value');
14580            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
14581          });
14582          previewSummary.innerHTML = ''
14583            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
14584            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
14585            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
14586            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
14587            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
14588            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
14589
14590          if (readinessSummary) {
14591            readinessSummary.innerHTML = ''
14592              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
14593              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
14594              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
14595          }
14596          } // end else (non-GIT_MODE)
14597        }
14598      }
14599
14600      function escapeHtml(value) {
14601        return String(value)
14602          .replace(/&/g, "&amp;")
14603          .replace(/</g, "&lt;")
14604          .replace(/>/g, "&gt;")
14605          .replace(/"/g, "&quot;")
14606          .replace(/'/g, "&#39;");
14607      }
14608
14609      function isPythonVisible() {
14610        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
14611      }
14612
14613      function syncPythonVisibility() {
14614        var html = previewPanel.textContent || "";
14615        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
14616        pythonWraps.forEach(function (node) {
14617          node.classList.toggle("hidden", !hasPython);
14618        });
14619      }
14620
14621      function attachPreviewInteractions() {
14622        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
14623        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
14624        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
14625        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
14626        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
14627        var searchInput = previewPanel.querySelector("#explorer-search");
14628        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
14629        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
14630        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
14631        var activeFilter = "all";
14632        var activeLanguage = "";
14633        var searchTerm = "";
14634        var currentSortKey = null;
14635        var currentSortOrder = "asc";
14636        var childRows = {};
14637
14638        rows.forEach(function (row) {
14639          var parentId = row.getAttribute("data-parent-id") || "";
14640          var rowId = row.getAttribute("data-row-id") || "";
14641          if (!childRows[parentId]) childRows[parentId] = [];
14642          childRows[parentId].push(rowId);
14643        });
14644
14645        function rowById(id) {
14646          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
14647        }
14648
14649        function hasCollapsedAncestor(row) {
14650          var parentId = row.getAttribute("data-parent-id");
14651          while (parentId) {
14652            var parent = rowById(parentId);
14653            if (!parent) break;
14654            if (parent.getAttribute("data-expanded") === "false") return true;
14655            parentId = parent.getAttribute("data-parent-id");
14656          }
14657          return false;
14658        }
14659
14660        function updateToggleGlyph(row) {
14661          var toggle = row.querySelector(".tree-toggle");
14662          if (!toggle) return;
14663          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
14664        }
14665
14666        function rowSortValue(row, key) {
14667          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
14668        }
14669
14670        function updateSortButtons() {
14671          sortButtons.forEach(function (button) {
14672            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
14673            var indicator = button.querySelector(".tree-sort-indicator");
14674            button.classList.toggle("active", isActive);
14675            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
14676            if (indicator) {
14677              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
14678            }
14679          });
14680        }
14681
14682        function sortSiblingRows() {
14683          if (!treeContainer) {
14684            updateSortButtons();
14685            return;
14686          }
14687
14688          var rowMap = {};
14689          var childrenMap = {};
14690          rows.forEach(function (row) {
14691            var rowId = row.getAttribute("data-row-id");
14692            var parentId = row.getAttribute("data-parent-id") || "";
14693            rowMap[rowId] = row;
14694            if (!childrenMap[parentId]) childrenMap[parentId] = [];
14695            childrenMap[parentId].push(rowId);
14696          });
14697
14698          Object.keys(childrenMap).forEach(function (parentId) {
14699            if (!parentId) return;
14700            childrenMap[parentId].sort(function (a, b) {
14701              var rowA = rowMap[a];
14702              var rowB = rowMap[b];
14703              if (!currentSortKey) {
14704                return Number(a) - Number(b);
14705              }
14706              var valueA = rowSortValue(rowA, currentSortKey);
14707              var valueB = rowSortValue(rowB, currentSortKey);
14708              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
14709              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
14710              var fallbackA = rowSortValue(rowA, "name");
14711              var fallbackB = rowSortValue(rowB, "name");
14712              if (fallbackA < fallbackB) return -1;
14713              if (fallbackA > fallbackB) return 1;
14714              return Number(a) - Number(b);
14715            });
14716          });
14717
14718          var orderedIds = [];
14719          function pushChildren(parentId) {
14720            (childrenMap[parentId] || []).forEach(function (childId) {
14721              orderedIds.push(childId);
14722              pushChildren(childId);
14723            });
14724          }
14725
14726          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
14727            orderedIds.push(topId);
14728            pushChildren(topId);
14729          });
14730
14731          orderedIds.forEach(function (id) {
14732            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
14733          });
14734          updateSortButtons();
14735        }
14736
14737        function updateLanguageButtons() {
14738          languageButtons.forEach(function (button) {
14739            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
14740            var isActive = languageValue === activeLanguage;
14741            button.classList.toggle("active", isActive);
14742          });
14743        }
14744
14745        function rowSelfMatches(row) {
14746          var kind = row.getAttribute("data-kind");
14747          var status = row.getAttribute("data-status");
14748          var language = (row.getAttribute("data-language") || "").toLowerCase();
14749          var name = row.getAttribute("data-name-lower") || "";
14750          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
14751          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
14752          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
14753          var passesLanguage = !activeLanguage || language === activeLanguage;
14754          return passesFilter && passesSearch && passesLanguage;
14755        }
14756
14757        function hasMatchingDescendant(rowId) {
14758          return (childRows[rowId] || []).some(function (childId) {
14759            var childRow = rowById(childId);
14760            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
14761          });
14762        }
14763
14764        function rowMatches(row) {
14765          if (rowSelfMatches(row)) return true;
14766          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
14767        }
14768
14769        function resetViewState() {
14770          activeFilter = "all";
14771          activeLanguage = "";
14772          searchTerm = "";
14773          currentSortKey = null;
14774          currentSortOrder = "asc";
14775          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
14776          if (searchInput) searchInput.value = "";
14777          if (filterSelect) filterSelect.value = "all";
14778          updateLanguageButtons();
14779        }
14780
14781        function applyVisibility() {
14782          rows.forEach(function (row) {
14783            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
14784            row.classList.toggle("hidden-by-filter", !visible);
14785            row.style.display = visible ? "grid" : "none";
14786          });
14787          buttons.forEach(function (button) {
14788            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
14789          });
14790          if (filterSelect) filterSelect.value = activeFilter;
14791        }
14792
14793        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
14794        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
14795        var originalStats = {};
14796        buttons.forEach(function (btn) {
14797          var f = btn.getAttribute('data-filter');
14798          var v = btn.querySelector('.scope-stat-value');
14799          if (f && v) originalStats[f] = v.textContent;
14800        });
14801
14802        function applySubmoduleStats(statsJson) {
14803          try {
14804            var s = JSON.parse(statsJson);
14805            buttons.forEach(function (btn) {
14806              var f = btn.getAttribute('data-filter');
14807              var v = btn.querySelector('.scope-stat-value');
14808              if (!v) return;
14809              if (f === 'dir') v.textContent = s.dirs;
14810              else if (f === 'file') v.textContent = s.files;
14811              else if (f === 'supported') v.textContent = s.supported;
14812              else if (f === 'skipped') v.textContent = s.skipped;
14813              else if (f === 'unsupported') v.textContent = s.unsupported;
14814            });
14815          } catch (e) {}
14816        }
14817
14818        function restoreBaseRepoStats() {
14819          buttons.forEach(function (btn) {
14820            var f = btn.getAttribute('data-filter');
14821            var v = btn.querySelector('.scope-stat-value');
14822            if (v && originalStats[f]) v.textContent = originalStats[f];
14823          });
14824          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
14825          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
14826        }
14827
14828        submoduleChips.forEach(function (chip) {
14829          chip.addEventListener('click', function () {
14830            var statsJson = chip.getAttribute('data-sub-stats');
14831            if (!statsJson) return;
14832            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
14833            chip.classList.add('active');
14834            applySubmoduleStats(statsJson);
14835            if (baseRepoBtn) baseRepoBtn.style.display = '';
14836          });
14837        });
14838
14839        if (baseRepoBtn) {
14840          baseRepoBtn.addEventListener('click', function () {
14841            restoreBaseRepoStats();
14842            resetViewState();
14843            sortSiblingRows();
14844            applyVisibility();
14845          });
14846        }
14847
14848        buttons.forEach(function (button) {
14849          button.addEventListener("click", function () {
14850            var filterValue = button.getAttribute("data-filter") || "all";
14851            if (filterValue === "reset-view") {
14852              restoreBaseRepoStats();
14853              resetViewState();
14854              sortSiblingRows();
14855              applyVisibility();
14856              return;
14857            }
14858            activeFilter = filterValue;
14859            applyVisibility();
14860          });
14861        });
14862
14863        rows.forEach(function (row) {
14864          updateToggleGlyph(row);
14865          var toggle = row.querySelector(".tree-toggle");
14866          if (toggle) {
14867            toggle.addEventListener("click", function () {
14868              var expanded = row.getAttribute("data-expanded") !== "false";
14869              row.setAttribute("data-expanded", expanded ? "false" : "true");
14870              updateToggleGlyph(row);
14871              applyVisibility();
14872            });
14873          }
14874        });
14875
14876        actionButtons.forEach(function (button) {
14877          button.addEventListener("click", function () {
14878            var action = button.getAttribute("data-explorer-action");
14879            if (action === "expand-all") {
14880              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
14881            } else if (action === "collapse-all") {
14882              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
14883            } else if (action === "clear-filters") {
14884              resetViewState();
14885            }
14886            sortSiblingRows();
14887            applyVisibility();
14888          });
14889        });
14890
14891        if (filterSelect) {
14892          filterSelect.addEventListener("change", function () {
14893            activeFilter = filterSelect.value || "all";
14894            applyVisibility();
14895          });
14896        }
14897
14898        languageButtons.forEach(function (button) {
14899          button.addEventListener("click", function () {
14900            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
14901            updateLanguageButtons();
14902            applyVisibility();
14903          });
14904        });
14905
14906        sortButtons.forEach(function (button) {
14907          button.addEventListener("click", function () {
14908            var sortKey = button.getAttribute("data-sort-key");
14909            if (currentSortKey === sortKey) {
14910              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
14911            } else {
14912              currentSortKey = sortKey;
14913              currentSortOrder = "asc";
14914            }
14915            sortSiblingRows();
14916            applyVisibility();
14917          });
14918        });
14919
14920        if (searchInput) {
14921          searchInput.addEventListener("input", function () {
14922            searchTerm = searchInput.value.trim().toLowerCase();
14923            applyVisibility();
14924          });
14925        }
14926
14927        updateLanguageButtons();
14928        sortSiblingRows();
14929        applyVisibility();
14930      }
14931
14932      function loadPreview() {
14933        if (!previewPanel || !pathInput) return;
14934        if (GIT_MODE) {
14935          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>';
14936          return;
14937        }
14938        var path = pathInput.value.trim();
14939        var zeroWarn = document.getElementById('zero-files-warning');
14940        if (!path) {
14941          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
14942          if (zeroWarn) zeroWarn.style.display = 'none';
14943          return;
14944        }
14945        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
14946        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
14947        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
14948        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
14949        var myGen = ++_previewGen;
14950        var _prevMsgs = [
14951          'Scanning directory structure…',
14952          'Detecting file types…',
14953          'Applying include / exclude filters…',
14954          'Estimating file counts…',
14955          'Building scope preview…',
14956          'Almost there…'
14957        ];
14958        var _prevMsgIdx = 0;
14959        var _prevStart = Date.now();
14960        previewPanel.innerHTML =
14961          '<div class="preview-loading">' +
14962          '<div class="preview-spinner"></div>' +
14963          '<div class="preview-loading-text">' +
14964          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
14965          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
14966          '</div></div>';
14967        var _sizeTextEl = document.getElementById('project-size-text');
14968        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
14969        window._previewInterval = setInterval(function() {
14970          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
14971          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
14972          var ml = document.getElementById('plm');
14973          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
14974        }, 1500);
14975        window._previewElapsedTimer = setInterval(function() {
14976          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
14977          var el = document.getElementById('ple');
14978          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
14979        }, 1000);
14980        var previewUrl = "/preview?path=" + encodeURIComponent(path)
14981          + "&include_globs=" + encodeURIComponent(includeValue)
14982          + "&exclude_globs=" + encodeURIComponent(excludeValue);
14983        fetch(previewUrl)
14984          .then(function (response) { return response.text(); })
14985          .then(function (html) {
14986            if (myGen !== _previewGen) return;
14987            clearInterval(window._previewInterval); window._previewInterval = null;
14988            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
14989            previewPanel.innerHTML = html;
14990            attachPreviewInteractions();
14991            syncPythonVisibility();
14992            updateReview();
14993            setTimeout(collapseLanguagePills, 50);
14994            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
14995            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
14996            var sizeText = document.getElementById('project-size-text');
14997            var sizeBtn = document.getElementById('project-size-btn');
14998            // In server mode with upload sizes available, keep the compressed/original pair.
14999            if (SERVER_MODE && window._lastUploadSizes) {
15000              var us = window._lastUploadSizes;
15001              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
15002                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
15003              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
15004                ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
15005            } else if (sizeText && projectSize) {
15006              sizeText.textContent = 'Project size: ' + projectSize;
15007              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
15008            } else if (sizeText) {
15009              sizeText.textContent = 'Project size: —';
15010            }
15011            if (zeroWarn) {
15012              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
15013              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
15014              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
15015              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
15016              if (supportedCount === 0 && fileCount > 0) {
15017                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).';
15018                zeroWarn.style.display = '';
15019              } else {
15020                zeroWarn.style.display = 'none';
15021              }
15022            }
15023          })
15024          .catch(function (err) {
15025            if (myGen !== _previewGen) return;
15026            clearInterval(window._previewInterval); window._previewInterval = null;
15027            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
15028            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
15029          });
15030      }
15031
15032      function pickDirectory(targetInput, kind) {
15033        if (SERVER_MODE) {
15034          if (kind === 'output') {
15035            showBannerToast(
15036              'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
15037              false,
15038              { top: true, icon: '📁' }
15039            );
15040            return;
15041          }
15042          var inputEl = kind === 'coverage'
15043            ? document.getElementById('cov-upload-input')
15044            : document.getElementById('dir-upload-input');
15045          if (!inputEl) return;
15046          inputEl.onchange = function () {
15047            var files = inputEl.files;
15048            if (!files || files.length === 0) return;
15049            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
15050            if (browseBtn) browseBtn.disabled = true;
15051
15052            function fileToBase64(file) {
15053              return new Promise(function (resolve, reject) {
15054                var reader = new FileReader();
15055                reader.onload = function () {
15056                  var b64 = reader.result.split(',')[1];
15057                  resolve(b64);
15058                };
15059                reader.onerror = reject;
15060                reader.readAsDataURL(file);
15061              });
15062            }
15063
15064            if (kind === 'coverage') {
15065              var f = files[0];
15066              if (previewPanel && targetInput === pathInput)
15067                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
15068              fileToBase64(f).then(function (b64) {
15069                return fetch('/api/upload-file', {
15070                  method: 'POST',
15071                  headers: { 'Content-Type': 'application/json' },
15072                  body: JSON.stringify({ filename: f.name, content: b64 })
15073                }).then(function (r) { return r.json(); });
15074              })
15075                .then(function (d) {
15076                  if (d && d.tmp_path) {
15077                    if (coverageInput) coverageInput.value = d.tmp_path;
15078                    setCovStatus('idle');
15079                  } else if (d && d.error) { showBannerToast(d.error, true); }
15080                })
15081                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
15082                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
15083            } else {
15084              // ── Filter to source-code files only ─────────────────────────
15085              // Binary, generated, and dependency files (node_modules, .git,
15086              // build artifacts) are skipped so they are never uploaded.
15087              var CODE_EXTS = new Set([
15088                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15089                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15090                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15091                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15092                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15093                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
15094                'tf','hcl','proto','thrift','avsc','graphql','gql'
15095              ]);
15096              var codeFiles = [];
15097              for (var i = 0; i < files.length; i++) {
15098                var f = files[i];
15099                var name = f.name;
15100                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
15101                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
15102                  codeFiles.push(f); continue;
15103                }
15104                var dot = name.lastIndexOf('.');
15105                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
15106              }
15107              // Collect specific .git metadata files for server-side git detection.
15108              // These have no source extension so they are excluded by the loop above,
15109              // but the server needs them to read branch/commit/author without running git.
15110              var gitMetaFiles = [];
15111              for (var i = 0; i < files.length; i++) {
15112                var f = files[i];
15113                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
15114                var gitIdx = rp.indexOf('/.git/');
15115                if (gitIdx < 0) continue;
15116                var gitRel = rp.slice(gitIdx + 1);
15117                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
15118                    gitRel === '.git/logs/HEAD' ||
15119                    gitRel.startsWith('.git/refs/heads/') ||
15120                    gitRel.startsWith('.git/refs/tags/')) {
15121                  gitMetaFiles.push(f);
15122                }
15123              }
15124              var uploadFiles = codeFiles.concat(gitMetaFiles);
15125              var total = files.length;
15126              var kept = codeFiles.length;
15127              if (kept === 0) {
15128                if (previewPanel && targetInput === pathInput)
15129                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
15130                if (browseBtn) browseBtn.disabled = false;
15131                inputEl.value = '';
15132                return;
15133              }
15134
15135              // ── Helper: apply upload result to UI ────────────────────────
15136              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
15137              function applyUploadResult(tmpPath, sizes) {
15138                targetInput.value = tmpPath;
15139                scrollInputToEnd(targetInput);
15140                if (sizes && SERVER_MODE) {
15141                  window._lastUploadSizes = sizes;
15142                  // Immediately show both sizes before preview loads.
15143                  var sizeText = document.getElementById('project-size-text');
15144                  var sizeBtn = document.getElementById('project-size-btn');
15145                  if (sizeText) {
15146                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15147                      ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15148                  }
15149                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15150                    ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15151                }
15152                if (targetInput === pathInput) {
15153                  updateReportTitleFromPath();
15154                  autoSetOutputDir(tmpPath);
15155                  fetchProjectHistory(tmpPath);
15156                  loadPreview();
15157                  suggestCoverageFile(tmpPath);
15158                }
15159                updateReview();
15160                if (browseBtn) browseBtn.disabled = false;
15161                inputEl.value = '';
15162              }
15163
15164              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
15165              if (typeof CompressionStream !== 'undefined') {
15166                if (previewPanel && targetInput === pathInput)
15167                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15168
15169                // Build a minimal POSIX ustar tar header for a single file entry.
15170                function buildUstarHeader(filePath, fileSize) {
15171                  var BLOCK = 512;
15172                  var hdr = new Uint8Array(BLOCK);
15173                  var enc = new TextEncoder();
15174                  function wStr(off, len, s) {
15175                    var b = enc.encode(s);
15176                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
15177                  }
15178                  function wOct(off, len, val) {
15179                    var s = val.toString(8);
15180                    while (s.length < len - 1) s = '0' + s;
15181                    wStr(off, len, s + '\0');
15182                  }
15183                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
15184                  var name = filePath, prefix = '';
15185                  if (filePath.length > 99) {
15186                    var split = filePath.lastIndexOf('/', 154);
15187                    if (split > 0 && filePath.length - split - 1 <= 99) {
15188                      prefix = filePath.substring(0, split);
15189                      name   = filePath.substring(split + 1);
15190                    } else { name = filePath.substring(0, 99); }
15191                  }
15192                  wStr(0,   100, name);          // name
15193                  wOct(100,   8, 0o000644);      // mode
15194                  wOct(108,   8, 0);             // uid
15195                  wOct(116,   8, 0);             // gid
15196                  wOct(124,  12, fileSize);      // size
15197                  wOct(136,  12, 0);             // mtime (epoch)
15198                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
15199                  hdr[156] = 48;                 // type flag '0' = regular file
15200                  wStr(157, 100, '');            // linkname
15201                  wStr(257,   6, 'ustar');       // magic
15202                  wStr(263,   2, '00');          // version
15203                  wStr(265,  32, '');            // uname
15204                  wStr(297,  32, '');            // gname
15205                  wOct(329,   8, 0);             // devmajor
15206                  wOct(337,   8, 0);             // devminor
15207                  wStr(345, 155, prefix);        // prefix
15208                  // Compute checksum (sum of all bytes, placeholder = 32).
15209                  var chk = 0;
15210                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15211                  var cs = chk.toString(8);
15212                  while (cs.length < 6) cs = '0' + cs;
15213                  wStr(148, 8, cs + '\0 ');
15214                  return hdr;
15215                }
15216
15217                // Build tar.gz one file at a time, piping through CompressionStream.
15218                // RAM usage = compressed output buffer + one file at a time.
15219                (async function () {
15220                  try {
15221                    var BLOCK = 512;
15222                    var cs     = new CompressionStream('gzip');
15223                    var writer = cs.writable.getWriter();
15224                    var chunks = [];
15225                    var reader = cs.readable.getReader();
15226                    var collecting = (async function () {
15227                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
15228                    })();
15229
15230                    for (var i = 0; i < uploadFiles.length; i++) {
15231                      var file = uploadFiles[i];
15232                      var path = file.webkitRelativePath || file.name;
15233                      var buf  = await file.arrayBuffer();
15234                      var data = new Uint8Array(buf);
15235                      // Header block
15236                      await writer.write(buildUstarHeader(path, data.length));
15237                      // Data padded to 512-byte boundary
15238                      if (data.length > 0) {
15239                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
15240                        var block  = new Uint8Array(padded);
15241                        block.set(data);
15242                        await writer.write(block);
15243                      }
15244                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
15245                        if (previewPanel && targetInput === pathInput)
15246                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15247                      }
15248                    }
15249                    // End-of-archive: two 512-byte zero blocks
15250                    await writer.write(new Uint8Array(BLOCK * 2));
15251                    await writer.close();
15252                    await collecting;
15253
15254                    var blob = new Blob(chunks, { type: 'application/gzip' });
15255                    var sizeMB = (blob.size / 1048576).toFixed(1);
15256                    if (previewPanel && targetInput === pathInput)
15257                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
15258
15259                    var resp = await fetch('/api/upload-tarball', {
15260                      method: 'POST',
15261                      headers: { 'Content-Type': 'application/gzip' },
15262                      body: blob
15263                    });
15264                    var d = await resp.json();
15265                    if (d && d.tmp_path) {
15266                      applyUploadResult(d.tmp_path, {
15267                        compressed_bytes: d.compressed_bytes || 0,
15268                        original_bytes: d.original_bytes || 0
15269                      });
15270                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15271                  } catch (e) {
15272                    showBannerToast('Upload failed: ' + String(e), true);
15273                    if (browseBtn) browseBtn.disabled = false;
15274                    inputEl.value = '';
15275                  }
15276                })();
15277
15278              } else {
15279                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
15280                // Used only on browsers that lack CompressionStream (pre-2023).
15281                var BATCH = 200;
15282                var batches = [];
15283                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
15284                var totalBatches = batches.length;
15285                if (previewPanel && targetInput === pathInput)
15286                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
15287
15288                function sendBatch(idx, currentUploadId, lastTmpPath) {
15289                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
15290                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
15291                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
15292                  Promise.all(batches[idx].map(function (file) {
15293                    return fileToBase64(file).then(function (b64) {
15294                      return { path: file.webkitRelativePath || file.name, content: b64 };
15295                    });
15296                  })).then(function (fileList) {
15297                    var body = { files: fileList };
15298                    if (currentUploadId) body.upload_id = currentUploadId;
15299                    return fetch('/api/upload-directory', {
15300                      method: 'POST', headers: { 'Content-Type': 'application/json' },
15301                      body: JSON.stringify(body)
15302                    }).then(function (r) { return r.json(); });
15303                  }).then(function (d) {
15304                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
15305                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
15306                  }).catch(function (e) {
15307                    showBannerToast('Upload failed: ' + String(e), true);
15308                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
15309                  });
15310                }
15311                sendBatch(0, null, '');
15312              }
15313            }
15314          };
15315          inputEl.click();
15316          return;
15317        }
15318
15319        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
15320        if (browseButton) browseButton.disabled = true;
15321
15322        if (previewPanel && targetInput === pathInput) {
15323          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
15324        }
15325
15326        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
15327          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
15328          .then(function (data) {
15329            if (data && data.selected_path) {
15330              targetInput.value = data.selected_path;
15331              scrollInputToEnd(targetInput);
15332
15333              if (targetInput === pathInput) {
15334                updateReportTitleFromPath();
15335                autoSetOutputDir(data.selected_path);
15336                fetchProjectHistory(data.selected_path);
15337                loadPreview();
15338                suggestCoverageFile(data.selected_path);
15339              }
15340
15341              updateReview();
15342            } else if (targetInput === pathInput) {
15343              loadPreview();
15344            }
15345          })
15346          .catch(function () {
15347            window.alert("Directory picker request failed.");
15348            if (previewPanel && targetInput === pathInput) {
15349              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
15350            }
15351          })
15352          .finally(function () {
15353            if (browseButton) browseButton.disabled = false;
15354          });
15355      }
15356
15357      if (themeToggle) {
15358        themeToggle.addEventListener("click", function () {
15359          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
15360          applyTheme(nextTheme);
15361          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
15362        });
15363      }
15364
15365      stepButtons.forEach(function (button) {
15366        button.addEventListener("click", function () {
15367          setStep(Number(button.getAttribute("data-step-target")));
15368        });
15369      });
15370
15371      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
15372        button.addEventListener("click", function () {
15373          setStep(Number(button.getAttribute("data-step-target")) || 1);
15374        });
15375      });
15376
15377      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
15378        button.addEventListener("click", function () {
15379          updateReview();
15380          setStep(Number(button.getAttribute("data-next")));
15381        });
15382      });
15383
15384      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
15385        button.addEventListener("click", function () {
15386          setStep(Number(button.getAttribute("data-prev")));
15387        });
15388      });
15389
15390      document.addEventListener("keydown", function (e) {
15391        var tag = (document.activeElement || {}).tagName || "";
15392        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
15393        if (e.altKey || e.ctrlKey || e.metaKey) return;
15394        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
15395        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
15396      });
15397
15398      if (useSamplePath) {
15399        useSamplePath.addEventListener("click", function () {
15400          pathInput.value = "tests/fixtures/basic";
15401          updateReportTitleFromPath();
15402          autoSetOutputDir("tests/fixtures/basic");
15403          loadPreview();
15404          suggestCoverageFile("tests/fixtures/basic");
15405        });
15406      }
15407
15408      if (useDefaultOutput) {
15409        useDefaultOutput.addEventListener("click", function () {
15410          delete outputDirInput.dataset.userEdited;
15411          autoSetOutputDir(pathInput ? pathInput.value : "");
15412          updateReview();
15413        });
15414      }
15415
15416      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
15417      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
15418
15419      // ── Drag-and-drop directory upload (server mode only) ─────────────────
15420      // Dropping a folder onto the path field bypasses Chrome's
15421      // "Upload X files to this site?" confirmation dialog.
15422      async function readDirRecursively(dirEntry, basePath) {
15423        var reader = dirEntry.createReader();
15424        var all = [];
15425        for (;;) {
15426          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
15427          if (!batch.length) break;
15428          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
15429        }
15430        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
15431        var out = [];
15432        for (var i = 0; i < all.length; i++) {
15433          var sub = all[i];
15434          if (sub.isFile) {
15435            var f = await new Promise(function(res) { sub.file(res); });
15436            out.push({ file: f, path: basePath + '/' + sub.name });
15437          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
15438            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
15439            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
15440          }
15441        }
15442        return out;
15443      }
15444
15445      function setupPathDropZone() {
15446        if (!SERVER_MODE || !pathInput) return;
15447        var CODE_EXTS = new Set([
15448          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
15449          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
15450          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
15451          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
15452          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
15453          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
15454        ]);
15455        pathInput.addEventListener('dragover', function(e) {
15456          e.preventDefault();
15457          pathInput.classList.add('drag-over');
15458        });
15459        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
15460        pathInput.addEventListener('drop', function(e) {
15461          e.preventDefault();
15462          pathInput.classList.remove('drag-over');
15463          var items = e.dataTransfer.items;
15464          if (!items || !items.length) return;
15465          var dirEntry = null;
15466          for (var i = 0; i < items.length; i++) {
15467            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
15468            if (entry && entry.isDirectory) { dirEntry = entry; break; }
15469          }
15470          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
15471          var btn = browsePath;
15472          if (btn) btn.disabled = true;
15473          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
15474
15475          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
15476            var total = allEntries.length;
15477            var codeEntries = allEntries.filter(function(e) {
15478              var n = e.file.name;
15479              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
15480              var dot = n.lastIndexOf('.');
15481              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
15482            });
15483            var kept = codeEntries.length;
15484            if (kept === 0) {
15485              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
15486              if (btn) btn.disabled = false; return;
15487            }
15488
15489            function finish(tmpPath, sizes) {
15490              pathInput.value = tmpPath;
15491              scrollInputToEnd(pathInput);
15492              if (sizes) {
15493                window._lastUploadSizes = sizes;
15494                var sizeText = document.getElementById('project-size-text');
15495                var sizeBtn = document.getElementById('project-size-btn');
15496                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
15497                  ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
15498                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
15499                  ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
15500              }
15501              updateReportTitleFromPath();
15502              autoSetOutputDir(tmpPath);
15503              fetchProjectHistory(tmpPath);
15504              loadPreview();
15505              suggestCoverageFile(tmpPath);
15506              updateReview();
15507              if (btn) btn.disabled = false;
15508            }
15509
15510            if (typeof CompressionStream === 'undefined') {
15511              showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
15512              if (btn) btn.disabled = false; return;
15513            }
15514
15515            try {
15516              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
15517              var BLOCK = 512;
15518              var cs = new CompressionStream('gzip');
15519              var wtr = cs.writable.getWriter();
15520              var chunks = [];
15521              var rdr = cs.readable.getReader();
15522              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
15523
15524              function buildHdr(fp, sz) {
15525                var hdr = new Uint8Array(BLOCK);
15526                var enc = new TextEncoder();
15527                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]; }
15528                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
15529                var nm = fp, pfx = '';
15530                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); } }
15531                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
15532                for (var i = 148; i < 156; i++) hdr[i] = 32;
15533                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);
15534                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
15535                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
15536                return hdr;
15537              }
15538
15539              for (var i = 0; i < codeEntries.length; i++) {
15540                var ce = codeEntries[i];
15541                var buf = await ce.file.arrayBuffer();
15542                var data = new Uint8Array(buf);
15543                await wtr.write(buildHdr(ce.path, data.length));
15544                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
15545                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
15546                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
15547              }
15548              await wtr.write(new Uint8Array(BLOCK * 2));
15549              await wtr.close();
15550              await collecting;
15551
15552              var blob = new Blob(chunks, { type: 'application/gzip' });
15553              var sizeMB = (blob.size / 1048576).toFixed(1);
15554              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
15555              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
15556              var d = await resp.json();
15557              if (d && d.tmp_path) {
15558                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
15559              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
15560            } catch (err) {
15561              showBannerToast('Upload failed: ' + String(err), true);
15562              if (btn) btn.disabled = false;
15563            }
15564          }).catch(function(err) {
15565            showBannerToast('Could not read folder: ' + String(err), true);
15566            if (btn) btn.disabled = false;
15567          });
15568        });
15569      }
15570      setupPathDropZone();
15571      if (browseCoverage) {
15572        browseCoverage.addEventListener("click", function () {
15573          pickDirectory(coverageInput || pathInput, "coverage");
15574        });
15575      }
15576
15577      function setCovStatus(state, opts) {
15578        if (!covScanStatus) return;
15579        opts = opts || {};
15580        covScanStatus.className = "cov-scan-status cov-scan-" + state;
15581        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
15582        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>';
15583        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>';
15584        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>';
15585        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>';
15586        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
15587        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
15588        if (state === "scanning") {
15589          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
15590        } else if (state === "found") {
15591          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15592          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
15593          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
15594          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
15595        } else if (state === "hint") {
15596          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
15597          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
15598          html += '<div class="cov-scan-sub">Generate one with:</div>';
15599          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
15600        } else if (state === "none") {
15601          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
15602          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
15603        }
15604        html += '</div></div>';
15605        covScanStatus.innerHTML = html;
15606        if (state === "found") {
15607          var useBtn = covScanStatus.querySelector(".cov-scan-use");
15608          if (useBtn) useBtn.addEventListener("click", function () {
15609            if (coverageInput) coverageInput.value = "";
15610            covAutoFilled = false;
15611            setCovStatus("idle");
15612          });
15613        }
15614      }
15615
15616      function suggestCoverageFile(projectPath) {
15617        if (!coverageInput || !covScanStatus) return;
15618        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15619        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
15620        clearTimeout(coverageSuggestTimer);
15621        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
15622        setCovStatus("scanning");
15623        coverageSuggestTimer = setTimeout(function () {
15624          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
15625            .then(function (r) { return r.json(); })
15626            .then(function (d) {
15627              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
15628              if (!d) { setCovStatus("none"); return; }
15629              if (d.found) {
15630                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
15631                setCovStatus("found", { found: d.found, tool: d.tool });
15632              } else if (d.tool && d.hint) {
15633                setCovStatus("hint", { tool: d.tool, hint: d.hint });
15634              } else {
15635                setCovStatus("none");
15636              }
15637            })
15638            .catch(function () { setCovStatus("idle"); });
15639        }, 600);
15640      }
15641
15642      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
15643
15644      if (coverageInput) coverageInput.addEventListener("input", function () {
15645        covAutoFilled = false;
15646        if (!this.value.trim()) setCovStatus("idle");
15647      });
15648
15649      // ── Language pill overflow: collapse to "+N more" chip ─────────────
15650      function collapseLanguagePills() {
15651        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
15652        rows.forEach(function(row) {
15653          // Remove any previous overflow chip
15654          var prev = row.querySelector('.lang-overflow-chip');
15655          if (prev) prev.remove();
15656          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
15657          pills.forEach(function(p) { p.style.display = ''; });
15658          if (!pills.length) return;
15659
15660          // Measure after restoring all pills
15661          var containerRight = row.getBoundingClientRect().right;
15662          var hidden = [];
15663          for (var i = pills.length - 1; i >= 1; i--) {
15664            var rect = pills[i].getBoundingClientRect();
15665            if (rect.right > containerRight + 2) {
15666              hidden.unshift(pills[i]);
15667              pills[i].style.display = 'none';
15668            } else {
15669              break;
15670            }
15671          }
15672
15673          if (hidden.length) {
15674            var chip = document.createElement('button');
15675            chip.type = 'button';
15676            chip.className = 'language-pill lang-overflow-chip';
15677            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
15678            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
15679            row.appendChild(chip);
15680          }
15681        });
15682      }
15683
15684      // Run after preview loads (preview panel populates language pills)
15685      var _origLoadPreviewCb = window.__previewLoaded;
15686      document.addEventListener('previewLoaded', collapseLanguagePills);
15687      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
15688      setTimeout(collapseLanguagePills, 400);
15689
15690      // ── Project history & output dir auto-set ──────────────────────────
15691      var wsOutputRoot   = document.getElementById("ws-output-root");
15692      var wsScanCount    = document.getElementById("ws-scan-count");
15693      var wsLastScan     = document.getElementById("ws-last-scan");
15694      var historyBadge   = document.getElementById("path-history-badge");
15695      var historyTimer   = null;
15696
15697      var wsOutputLink = document.getElementById("ws-output-link");
15698      function syncStripOutputRoot() {
15699        var val = outputDirInput ? outputDirInput.value : "";
15700        var display = val || "project/sloc";
15701        if (wsOutputRoot) wsOutputRoot.textContent = display;
15702        if (wsOutputLink) wsOutputLink.dataset.folder = val;
15703      }
15704
15705      function scrollInputToEnd(input) {
15706        if (!input) return;
15707        // Defer so the DOM has the new value before we measure scroll width.
15708        requestAnimationFrame(function () {
15709          input.scrollLeft = input.scrollWidth;
15710          input.selectionStart = input.selectionEnd = input.value.length;
15711        });
15712      }
15713
15714      function autoSetOutputDir(projectPath) {
15715        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
15716        if (GIT_MODE && GIT_OUTPUT_DIR) {
15717          outputDirInput.value = GIT_OUTPUT_DIR;
15718          scrollInputToEnd(outputDirInput);
15719          syncStripOutputRoot();
15720          updateReview();
15721          return;
15722        }
15723        if (!projectPath || !projectPath.trim()) return;
15724        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
15725        outputDirInput.value = cleaned + "/sloc";
15726        scrollInputToEnd(outputDirInput);
15727        syncStripOutputRoot();
15728        updateReview();
15729      }
15730
15731      var wsBranch = document.getElementById("ws-branch");
15732
15733      function fetchProjectHistory(projectPath) {
15734        if (!projectPath || !projectPath.trim()) {
15735          if (wsScanCount) wsScanCount.textContent = "—";
15736          if (wsLastScan)  wsLastScan.textContent  = "—";
15737          if (wsBranch)    wsBranch.textContent    = "—";
15738          if (historyBadge) historyBadge.style.display = "none";
15739          return;
15740        }
15741        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
15742          .then(function (r) { return r.ok ? r.json() : null; })
15743          .then(function (data) {
15744            if (!data) return;
15745            var countStr = data.scan_count > 0
15746              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
15747              : "never";
15748            var tsStr = data.last_scan_timestamp
15749              ? data.last_scan_timestamp.replace(" UTC","")
15750              : "—";
15751            if (wsScanCount) wsScanCount.textContent = countStr;
15752            if (wsLastScan)  wsLastScan.textContent  = tsStr;
15753            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
15754            if (data.scan_count > 0) {
15755              if (historyBadge) {
15756                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
15757                historyBadge.textContent = data.scan_count + " previous scan" +
15758                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
15759                  "Last: " + (data.last_scan_timestamp || "—") +
15760                  " — " + (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.";
15761                historyBadge.className = "path-history-badge found";
15762                historyBadge.style.display = "";
15763              }
15764            } else {
15765              if (historyBadge) historyBadge.style.display = "none";
15766            }
15767          })
15768          .catch(function () {});
15769      }
15770
15771      function onPathChange() {
15772        var val = pathInput ? pathInput.value : "";
15773        // Discard stale upload sizes when the user edits the path manually.
15774        window._lastUploadSizes = null;
15775        updateReportTitleFromPath();
15776        autoSetOutputDir(val);
15777        updateSidebarSummary();
15778        clearTimeout(historyTimer);
15779        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
15780        if (previewTimer) clearTimeout(previewTimer);
15781        previewTimer = setTimeout(loadPreview, 280);
15782        suggestCoverageFile(val);
15783      }
15784
15785      if (pathInput) {
15786        pathInput.addEventListener("input", onPathChange);
15787      }
15788
15789      if (outputDirInput) {
15790        outputDirInput.addEventListener("input", function () {
15791          outputDirInput.dataset.userEdited = "1";
15792          syncStripOutputRoot();
15793          updateReview();
15794        });
15795      }
15796
15797      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
15798        if (!node) return;
15799        node.addEventListener("input", function () {
15800          updateReview();
15801          if (previewTimer) clearTimeout(previewTimer);
15802          previewTimer = setTimeout(loadPreview, 280);
15803        });
15804      });
15805
15806      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
15807        var node = document.getElementById(id);
15808        if (node) node.addEventListener("change", updateReview);
15809      });
15810
15811      if (reportTitleInput) {
15812        reportTitleInput.addEventListener("input", function () {
15813          reportTitleTouched = reportTitleInput.value.trim().length > 0;
15814          updateReportTitleFromPath();
15815          updateReview();
15816        });
15817      }
15818
15819      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
15820      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
15821      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
15822      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
15823
15824      if (coverageInput) {
15825        coverageInput.addEventListener("input", function () {
15826          if (coverageInput.value.trim()) setCovStatus("idle");
15827        });
15828      }
15829
15830      if (form && loading && submitButton) {
15831        form.addEventListener("submit", function (e) {
15832          e.preventDefault();
15833          submitButton.disabled = true;
15834          submitButton.textContent = "Scanning...";
15835          startAsyncAnalysis(new FormData(form));
15836        });
15837      }
15838
15839      function openPath(folder) {
15840        if (!folder) return;
15841        fetch('/open-path?path=' + encodeURIComponent(folder))
15842          .then(function (r) { return r.json(); })
15843          .then(function (d) {
15844            if (d && d.server_mode_disabled)
15845              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
15846          })
15847          .catch(function () {});
15848      }
15849
15850      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
15851        btn.addEventListener('click', function () {
15852          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
15853        });
15854      });
15855
15856      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
15857      if (wsOutputLink) {
15858        wsOutputLink.addEventListener('click', function () {
15859          openPath(wsOutputLink.dataset.folder || '');
15860        });
15861      }
15862
15863      loadSavedTheme();
15864      updateMixedPolicyUI();
15865      updatePythonDocstringUI();
15866      applyScanPreset();
15867      updatePresetDescriptions();
15868      applyArtifactPreset();
15869      updateReview();
15870      updateScrollProgress(); // initialise bar to 0% (step 1)
15871      window.addEventListener("scroll", updateScrollProgress, { passive: true });
15872      onPathChange();         // seed output dir, history badge, and preview from initial path
15873      updateStepNav(1);
15874
15875      // Restore step from URL hash on initial load (e.g., back-forward cache)
15876      (function() {
15877        var hashMatch = location.hash.match(/^#step([1-4])$/);
15878        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
15879      })();
15880
15881      (function randomizeWatermarks() {
15882        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
15883        if (!wms.length) return;
15884        var placed = [];
15885        function tooClose(top, left) {
15886          for (var i = 0; i < placed.length; i++) {
15887            var dt = Math.abs(placed[i][0] - top);
15888            var dl = Math.abs(placed[i][1] - left);
15889            if (dt < 16 && dl < 12) return true;
15890          }
15891          return false;
15892        }
15893        function pick(leftBand) {
15894          for (var attempt = 0; attempt < 50; attempt++) {
15895            var top = Math.random() * 88 + 2;
15896            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
15897            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
15898          }
15899          var top = Math.random() * 88 + 2;
15900          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
15901          placed.push([top, left]);
15902          return [top, left];
15903        }
15904        var half = Math.floor(wms.length / 2);
15905        wms.forEach(function (img, i) {
15906          var pos = pick(i < half);
15907          var size = Math.floor(Math.random() * 80 + 110);
15908          var rot = (Math.random() * 360).toFixed(1);
15909          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
15910          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;
15911        });
15912      })();
15913
15914      (function spawnCodeParticles() {
15915        var container = document.getElementById('code-particles');
15916        if (!container) return;
15917        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'];
15918        for (var i = 0; i < 38; i++) {
15919          (function(idx) {
15920            var el = document.createElement('span');
15921            el.className = 'code-particle';
15922            el.textContent = snippets[idx % snippets.length];
15923            var left = Math.random() * 94 + 2;
15924            var top = Math.random() * 88 + 6;
15925            var dur = (Math.random() * 10 + 9).toFixed(1);
15926            var delay = (Math.random() * 18).toFixed(1);
15927            var rot = (Math.random() * 26 - 13).toFixed(1);
15928            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15929            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';
15930            container.appendChild(el);
15931          })(i);
15932        }
15933      })();
15934    })();
15935  </script>
15936  <script nonce="{{ csp_nonce }}">
15937    (function () {
15938      var raw = {{ prefill_json|safe }};
15939      if (!raw || typeof raw !== 'object' || !raw.path) return;
15940      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
15941      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
15942      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
15943      setVal('path', raw.path || '');
15944      setVal('include_globs', raw.include_globs || '');
15945      setVal('exclude_globs', raw.exclude_globs || '');
15946      setVal('output_dir', raw.output_dir || '');
15947      setVal('report_title', raw.report_title || '');
15948      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
15949      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
15950      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
15951      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
15952      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
15953      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
15954      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
15955      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
15956      setChecked('generate_html', raw.generate_html !== false);
15957      setChecked('generate_pdf', !!raw.generate_pdf);
15958      // Trigger dynamic UI updates after pre-fill.
15959      setTimeout(function () {
15960        var pathEl = document.getElementById('path');
15961        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
15962        var policyEl = document.getElementById('mixed_line_policy');
15963        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
15964      }, 80);
15965    })();
15966  </script>
15967  <script nonce="{{ csp_nonce }}">
15968  (function(){
15969    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'}];
15970    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);});}
15971    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15972    function init(){
15973      var btn=document.getElementById('settings-btn');if(!btn)return;
15974      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15975      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>';
15976      document.body.appendChild(m);
15977      var g=document.getElementById('scheme-grid');
15978      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);});
15979      var cl=document.getElementById('settings-close');
15980      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);
15981      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');});
15982      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15983      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15984    }
15985    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15986  }());
15987  </script>
15988  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
15989    <div class="wb-ftip-arrow"></div>
15990    <span id="wb-ftip-text"></span>
15991  </div>
15992  <script nonce="{{ csp_nonce }}">(function(){
15993    var tip=document.getElementById('wb-ftip');
15994    var txt=document.getElementById('wb-ftip-text');
15995    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
15996    if(!tip||!txt)return;
15997    function pos(el){
15998      var r=el.getBoundingClientRect();
15999      tip.style.display='block';
16000      var tw=tip.offsetWidth;
16001      var lx=r.left+r.width/2-tw/2;
16002      if(lx<8)lx=8;
16003      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
16004      tip.style.left=lx+'px';
16005      tip.style.top=(r.bottom+8)+'px';
16006      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';}
16007    }
16008    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
16009      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
16010      el.addEventListener('mouseleave',function(){tip.style.display='none';});
16011    });
16012  })();
16013  (function(){
16014    function fixArtifactHintSpacing(){
16015      var grid=document.querySelector('.artifact-grid');
16016      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
16017    }
16018    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
16019  }());
16020  (function(){
16021    var dot=document.getElementById('status-dot');
16022    var pingEl=document.getElementById('server-ping-ms');
16023    var tipEl=document.getElementById('server-tip-ping');
16024    var fm=document.getElementById('footer-mode');
16025    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)';}}
16026    function doPing(){
16027      var t0=performance.now();
16028      fetch('/healthz',{cache:'no-store'})
16029        .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);})
16030        .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)';}});
16031    }
16032    doPing();
16033    setInterval(doPing,5000);
16034    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');}
16035  })();
16036  </script>
16037  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
16038  <footer class="site-footer">
16039    local code analysis - metrics, history and reports
16040    &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>
16041    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16042    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16043    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16044    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16045  </footer>
16046</body>
16047</html>
16048"##,
16049    ext = "html"
16050)]
16051struct IndexTemplate {
16052    version: &'static str,
16053    prefill_json: String,
16054    csp_nonce: String,
16055    git_repo: String,
16056    git_ref: String,
16057    git_label_json: String,
16058    git_output_dir_json: String,
16059    server_mode: bool,
16060}
16061
16062// ── SplashTemplate ────────────────────────────────────────────────────────────
16063
16064#[derive(Template)]
16065#[template(
16066    source = r##"
16067<!doctype html>
16068<html lang="en">
16069<head>
16070  <meta charset="utf-8">
16071  <meta name="viewport" content="width=device-width, initial-scale=1">
16072  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
16073  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16074  <style nonce="{{ csp_nonce }}">
16075    :root {
16076      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
16077      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16078      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16079      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16080      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16081    }
16082    body.dark-theme {
16083      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16084      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16085    }
16086    *{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;}
16087    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16088    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16089    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16090    .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;}
16091    @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));}}
16092    .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);}
16093    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16094    .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));}
16095    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16096    .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;}
16097    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16098    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16099    @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; } }
16100    .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;}
16101    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16102    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16103    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16104    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16105    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16106    .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;}
16107    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16108    .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);}
16109    .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;}
16110    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16111    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16112    .settings-modal-body{padding:14px 16px 16px;}
16113    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16114    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16115    .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;}
16116    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16117    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16118    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16119    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16120    .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;}
16121    .tz-select:focus{border-color:var(--oxide);}
16122    .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;}
16123    .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;}
16124    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
16125    .hero{text-align:center;margin:0 auto 18px;}
16126    .hero-logo-wrap{display:inline-block;cursor:default;}
16127    .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;}
16128    .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;}
16129    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
16130    .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;}
16131    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%);}
16132    .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;
16133      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
16134      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
16135      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;}
16136    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
16137    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
16138    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;}
16139    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
16140    .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;}
16141    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
16142    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
16143    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
16144    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
16145    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
16146    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
16147    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
16148    .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;}
16149    .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;}
16150    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16151    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
16152    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16153    .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);}
16154    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
16155    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
16156    .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);}
16157    .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);}
16158    .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);}
16159    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
16160    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
16161    .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;}
16162    body.dark-theme .action-card-cta{color:var(--oxide);}
16163    .action-card.view .action-card-cta{color:var(--accent-2);}
16164    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
16165    .action-card.compare .action-card-cta{color:#7c3aed;}
16166    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
16167    .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);}
16168    .action-card.git-tools .action-card-cta{color:#15803d;}
16169    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
16170    .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);}
16171    .action-card.trend .action-card-cta{color:#0e7490;}
16172    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
16173    .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);}
16174    .action-card.automation .action-card-cta{color:#b45309;}
16175    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
16176    .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);}
16177    .action-card.test-metrics .action-card-cta{color:#be185d;}
16178    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
16179    .action-card:hover .action-card-cta{gap:12px;}
16180    .action-card.card-split{flex-direction:row;align-items:stretch;}
16181    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
16182    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
16183    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
16184    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
16185    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
16186    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
16187    .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;}
16188    .ac-badge.active{opacity:1;}
16189    .ac-badge.github{border-color:#555;color:#555;}
16190    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
16191    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
16192    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
16193    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
16194    body.dark-theme .ac-right-row{color:var(--muted);}
16195    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
16196    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
16197    .divider{height:1px;background:var(--line);margin:32px 0;}
16198    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
16199    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
16200    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
16201    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
16202      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
16203    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
16204    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
16205    body.dark-theme .info-chip-val{color:var(--oxide);}
16206    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
16207    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
16208      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
16209      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
16210    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
16211      border:6px solid transparent;border-top-color:var(--text);}
16212    .info-chip:hover .info-chip-tip{display:block;}
16213    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
16214    .chip-slide.fading{filter:blur(5px);opacity:0;}
16215    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16216    .site-footer a{color:var(--muted);}
16217    .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;}
16218    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
16219    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
16220    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
16221    .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;}
16222    .lan-badge.local{background:var(--oxide-2);}
16223    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
16224    .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);}
16225    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
16226    .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;}
16227    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
16228    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
16229    .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;}
16230    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
16231    .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;}
16232    .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);}
16233    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
16234    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
16235    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
16236    .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;}
16237    @media (max-height: 1100px) {
16238      .page{padding-top:10px;}
16239      .hero{margin-bottom:10px;}
16240      .hero-logo{width:54px;height:60px;}
16241      .hero-logo-shadow{width:42px;}
16242      .hero-title{font-size:28px;}
16243      .hero-subtitle{font-size:13px;}
16244      .card-sections{gap:12px;margin-bottom:6px;}
16245      .card-section-grid-2,.card-section-grid-3{gap:10px;}
16246      .action-card{padding:8px 15px 8px;}
16247      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
16248      .action-card-icon svg{width:18px;height:18px;}
16249      .action-card-title{font-size:13px;}
16250      .action-card-desc{font-size:11px;margin-bottom:6px;}
16251      .action-card-cta{font-size:11px;}
16252      .ac-right-row{font-size:11px;}
16253      .divider{margin:14px 0;}
16254      .info-strip{gap:7px;margin-bottom:8px;}
16255      .info-chip{padding:7px 10px;}
16256      .info-chip-val{font-size:13px;}
16257      .info-chip-label{font-size:9px;}
16258      .site-footer{padding:8px 24px;font-size:12px;}
16259      .lan-local-hint{margin-top:8px;}
16260    }
16261    @media (max-height: 850px) {
16262      .page{padding-top:6px;}
16263      .hero{margin-bottom:6px;}
16264      .hero-logo{width:42px;height:46px;}
16265      .hero-title{font-size:22px;}
16266      .hero-subtitle{font-size:12px;}
16267      .card-sections{gap:10px;}
16268      .action-card-desc{margin-bottom:4px;}
16269      .divider{margin:8px 0;}
16270      .info-strip{margin-bottom:6px;}
16271      .lan-local-hint{margin-top:10px;}
16272    }
16273  </style>
16274</head>
16275<body>
16276  <div class="background-watermarks" aria-hidden="true">
16277    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16278    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16279    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16280    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16281    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16282    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16283    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16284  </div>
16285  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16286  <div class="top-nav">
16287    <div class="top-nav-inner">
16288      <a class="brand" href="/">
16289        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16290        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16291      </a>
16292      <div class="nav-right">
16293        <a class="nav-pill" href="/">Home</a>
16294        <div class="nav-dropdown">
16295          <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>
16296          <div class="nav-dropdown-menu">
16297            <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>
16298          </div>
16299        </div>
16300        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16301        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16302        <div class="nav-dropdown">
16303          <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>
16304          <div class="nav-dropdown-menu">
16305            <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>
16306          </div>
16307        </div>
16308        <div class="server-status-wrap" id="server-status-wrap">
16309          <div class="nav-pill server-online-pill" id="server-status-pill">
16310            <span class="status-dot" id="status-dot"></span>
16311            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16312            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16313          </div>
16314          <div class="server-status-tip">
16315            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
16316            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16317          </div>
16318        </div>
16319        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16320          <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>
16321        </button>
16322        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16323          <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>
16324          <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>
16325        </button>
16326      </div>
16327    </div>
16328  </div>
16329
16330  <div class="page">
16331    <div class="hero">
16332      <div class="hero-logo-wrap" id="hero-logo-wrap">
16333        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
16334      </div>
16335      <div class="hero-logo-shadow"></div>
16336      <div class="hero-title-wrap">
16337        <div class="hero-title-aura" aria-hidden="true"></div>
16338        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
16339      </div>
16340      <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>
16341    </div>
16342
16343    <div class="card-sections">
16344
16345      <div>
16346        <div class="card-section-label">Analysis</div>
16347        <div class="card-section-grid-2">
16348          <a class="action-card scan card-split" href="/scan-setup">
16349            <div class="action-card-left">
16350              <div class="action-card-icon">
16351                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
16352              </div>
16353              <div class="action-card-title">Scan Project</div>
16354              <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>
16355              <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>
16356            </div>
16357            <div class="action-card-sep"></div>
16358            <div class="action-card-right">
16359              <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>
16360              <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>
16361              <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>
16362              <div class="ac-right-stat" id="acp-scan-stat"></div>
16363            </div>
16364          </a>
16365          <a class="action-card test-metrics card-split" href="/test-metrics">
16366            <div class="action-card-left">
16367              <div class="action-card-icon">
16368                <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>
16369              </div>
16370              <div class="action-card-title">Test Metrics</div>
16371              <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>
16372              <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>
16373            </div>
16374            <div class="action-card-sep"></div>
16375            <div class="action-card-right">
16376              <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>
16377              <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>
16378              <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>
16379              <div class="ac-right-stat" id="acp-test-stat"></div>
16380            </div>
16381          </a>
16382        </div>
16383      </div>
16384
16385      <div>
16386        <div class="card-section-label">Reports &amp; Insights</div>
16387        <div class="card-section-grid-3">
16388          <a class="action-card view" href="/view-reports">
16389            <div class="action-card-icon">
16390              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
16391            </div>
16392            <div class="action-card-title">View Reports</div>
16393            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
16394            <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>
16395          </a>
16396          <a class="action-card compare" href="/compare-scans">
16397            <div class="action-card-icon">
16398              <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>
16399            </div>
16400            <div class="action-card-title">Compare Scans</div>
16401            <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>
16402            <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>
16403          </a>
16404          <a class="action-card trend" href="/trend-reports">
16405            <div class="action-card-icon">
16406              <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>
16407            </div>
16408            <div class="action-card-title">Trend Report</div>
16409            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
16410            <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>
16411          </a>
16412        </div>
16413      </div>
16414
16415      <div>
16416        <div class="card-section-label">Developer Tools</div>
16417        <div class="card-section-grid-2">
16418          <a class="action-card git-tools card-split" href="/git-browser">
16419            <div class="action-card-left">
16420              <div class="action-card-icon">
16421                <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>
16422              </div>
16423              <div class="action-card-title">Git Browser</div>
16424              <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>
16425              <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>
16426            </div>
16427            <div class="action-card-sep"></div>
16428            <div class="action-card-right">
16429              <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>
16430              <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>
16431              <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>
16432            </div>
16433          </a>
16434          <a class="action-card automation card-split" href="/integrations">
16435            <div class="action-card-left">
16436              <div class="action-card-icon">
16437                <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>
16438              </div>
16439              <div class="action-card-title">Integrations</div>
16440              <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>
16441              <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>
16442            </div>
16443            <div class="action-card-sep"></div>
16444            <div class="action-card-right">
16445              <div class="ac-badges-grid">
16446                <span class="ac-badge github"     id="acp-gh">GitHub</span>
16447                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
16448                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
16449                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
16450              </div>
16451              <div class="ac-right-stat" id="acp-int-stat"></div>
16452            </div>
16453          </a>
16454        </div>
16455      </div>
16456
16457    </div>
16458
16459    {% if server_mode %}
16460    <div class="lan-card server">
16461      <div class="lan-card-header">
16462        <span class="lan-badge">LAN server</span>
16463        Accessible on your network
16464      </div>
16465      {% if let Some(ip) = lan_ip %}
16466      <div class="lan-url-row">
16467        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
16468        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
16469          <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>
16470          Copy URL
16471        </button>
16472      </div>
16473      <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>
16474      {% if has_api_key %}
16475      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
16476      {% endif %}
16477      {% else %}
16478      <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>
16479      {% endif %}
16480    </div>
16481    {% endif %}
16482
16483    <div class="divider"></div>
16484
16485    <div class="info-strip">
16486      <div class="info-chip">
16487        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
16488        <div class="chip-slide">
16489          <div class="info-chip-val">41</div>
16490          <div class="info-chip-label">Languages</div>
16491        </div>
16492      </div>
16493      <div class="info-chip">
16494        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
16495        <div class="chip-slide">
16496          <div class="info-chip-val">100%</div>
16497          <div class="info-chip-label">Self-contained</div>
16498        </div>
16499      </div>
16500      <div class="info-chip">
16501        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
16502        <div class="chip-slide">
16503          <div class="info-chip-val">HTML+PDF</div>
16504          <div class="info-chip-label">Exportable reports</div>
16505        </div>
16506      </div>
16507      <div class="info-chip">
16508        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
16509        <div class="chip-slide">
16510          <div class="info-chip-val">Webhook</div>
16511          <div class="info-chip-label">3 platforms</div>
16512        </div>
16513      </div>
16514      <div class="info-chip">
16515        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
16516        <div class="chip-slide">
16517          <div class="info-chip-val">IEEE</div>
16518          <div class="info-chip-label">1045-1992</div>
16519        </div>
16520      </div>
16521    </div>
16522
16523    {% if lan_ip.is_none() %}
16524    <div class="lan-local-hint">
16525      <strong>Want teammates on the same network to access this?</strong><br>
16526      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
16527    </div>
16528    {% endif %}
16529  </div>
16530
16531  <footer class="site-footer">
16532    local code analysis - metrics, history and reports
16533    &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>
16534    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16535    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16536    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16537    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16538  </footer>
16539
16540  <script nonce="{{ csp_nonce }}">
16541    (function () {
16542      var storageKey = 'oxide-sloc-theme';
16543      var body = document.body;
16544      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16545      var toggle = document.getElementById('theme-toggle');
16546      if (toggle) toggle.addEventListener('click', function () {
16547        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16548        body.classList.toggle('dark-theme', next === 'dark');
16549        try { localStorage.setItem(storageKey, next); } catch(e) {}
16550      });
16551      var copyBtn = document.getElementById('lan-copy-btn');
16552      if (copyBtn) copyBtn.addEventListener('click', function() {
16553        var btn = this;
16554        var el = document.getElementById('lan-url-val');
16555        if (!el) return;
16556        var url = el.textContent.trim();
16557        if (navigator.clipboard) {
16558          navigator.clipboard.writeText(url).then(function() {
16559            var orig = btn.innerHTML;
16560            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!';
16561            setTimeout(function() { btn.innerHTML = orig; }, 1800);
16562          });
16563        }
16564      });
16565      (function randomizeWatermarks() {
16566        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16567        if (!wms.length) return;
16568        var placed = [];
16569        function tooClose(top, left) {
16570          for (var i = 0; i < placed.length; i++) {
16571            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16572            if (dt < 16 && dl < 12) return true;
16573          }
16574          return false;
16575        }
16576        function pick(leftBand) {
16577          for (var attempt = 0; attempt < 50; attempt++) {
16578            var top = Math.random() * 88 + 2;
16579            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16580            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
16581          }
16582          var top = Math.random() * 88 + 2;
16583          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16584          placed.push([top, left]); return [top, left];
16585        }
16586        var half = Math.floor(wms.length / 2);
16587        wms.forEach(function (img, i) {
16588          var pos = pick(i < half);
16589          var size = Math.floor(Math.random() * 100 + 120);
16590          var rot = (Math.random() * 360).toFixed(1);
16591          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16592          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;
16593        });
16594      })();
16595
16596      (function spawnCodeParticles() {
16597        var container = document.getElementById('code-particles');
16598        if (!container) return;
16599        var snippets = [
16600          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
16601          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
16602          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
16603          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
16604          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
16605        ];
16606        var count = 38;
16607        for (var i = 0; i < count; i++) {
16608          (function(idx) {
16609            var el = document.createElement('span');
16610            el.className = 'code-particle';
16611            var text = snippets[idx % snippets.length];
16612            el.textContent = text;
16613            var left = Math.random() * 94 + 2;
16614            var top = Math.random() * 88 + 6;
16615            var dur = (Math.random() * 10 + 9).toFixed(1);
16616            var delay = (Math.random() * 18).toFixed(1);
16617            var rot = (Math.random() * 26 - 13).toFixed(1);
16618            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16619            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
16620              + '--rot:' + rot + 'deg;--op:' + op + ';'
16621              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
16622            container.appendChild(el);
16623          })(i);
16624        }
16625      })();
16626      (function heroAnimations() {
16627        var sub = document.getElementById('hero-subtitle');
16628        if (sub) {
16629          var full = sub.textContent.trim();
16630          sub.textContent = '';
16631          sub.style.opacity = '1';
16632          var cursor = document.createElement('span');
16633          cursor.className = 'hero-cursor';
16634          sub.appendChild(cursor);
16635          var i = 0;
16636          setTimeout(function() {
16637            var iv = setInterval(function() {
16638              if (i < full.length) {
16639                sub.insertBefore(document.createTextNode(full[i]), cursor);
16640                i++;
16641              } else {
16642                clearInterval(iv);
16643                setTimeout(function() {
16644                  cursor.style.transition = 'opacity 1s ease';
16645                  cursor.style.opacity = '0';
16646                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
16647                }, 2400);
16648              }
16649            }, 11);
16650          }, 374);
16651        }
16652      })();
16653      (function logoBob() {
16654        var logo = document.querySelector('.hero-logo');
16655        var shadow = document.querySelector('.hero-logo-shadow');
16656        if (!logo) return;
16657        var cycleStart = null, cycleDur = 3600;
16658        var peakY = -14, peakScale = 1.07, peakRot = 0;
16659        function newCycle() {
16660          cycleDur = 3000 + Math.random() * 1840;
16661          peakY = -(9 + Math.random() * 13.8);
16662          peakScale = 1.04 + Math.random() * 0.081;
16663          peakRot = (Math.random() * 11.5 - 5.75);
16664        }
16665        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
16666        newCycle();
16667        function frame(ts) {
16668          if (cycleStart === null) cycleStart = ts;
16669          var t = (ts - cycleStart) / cycleDur;
16670          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
16671          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
16672          var y = peakY * phase;
16673          var sc = 1 + (peakScale - 1) * phase;
16674          var rot = peakRot * Math.sin(Math.PI * phase);
16675          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
16676          if (shadow) {
16677            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
16678            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
16679          }
16680          requestAnimationFrame(frame);
16681        }
16682        requestAnimationFrame(frame);
16683      })();
16684      (function mouseEffects() {
16685        var heroTitle = document.getElementById('hero-title');
16686        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
16687        function tick() {
16688          raf = null;
16689          if (heroTitle) {
16690            var r = heroTitle.getBoundingClientRect();
16691            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
16692            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
16693            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
16694          }
16695        }
16696        document.addEventListener('mousemove', function(e) {
16697          mx = e.clientX; my = e.clientY;
16698          if (!raf) raf = requestAnimationFrame(tick);
16699        });
16700        document.addEventListener('mouseleave', function() {
16701          if (heroTitle) {
16702            heroTitle.style.transition = 'transform 0.5s ease';
16703            heroTitle.style.transform = '';
16704            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
16705          }
16706        });
16707        document.querySelectorAll('.action-card').forEach(function(card) {
16708          card.addEventListener('mousemove', function(e) {
16709            var rect = card.getBoundingClientRect();
16710            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
16711            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
16712            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
16713            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
16714          });
16715          card.addEventListener('mouseleave', function() {
16716            card.style.transition = '';
16717            card.style.transform = '';
16718          });
16719        });
16720      })();
16721      (function chipSlideshow() {
16722        var slides = [
16723          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
16724          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
16725          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
16726          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
16727          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
16728        ];
16729        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
16730        var indices = [0,0,0,0,0];
16731        var paused = [false,false,false,false,false];
16732        chips.forEach(function(chip, i) {
16733          chip.addEventListener('mouseenter', function() { paused[i] = true; });
16734          chip.addEventListener('mouseleave', function() { paused[i] = false; });
16735        });
16736        function advance(i) {
16737          if (paused[i]) return;
16738          var chip = chips[i];
16739          var inner = chip.querySelector('.chip-slide');
16740          if (!inner) return;
16741          inner.classList.add('fading');
16742          setTimeout(function() {
16743            indices[i] = (indices[i] + 1) % slides[i].length;
16744            var s = slides[i][indices[i]];
16745            chip.querySelector('.info-chip-val').textContent = s.v;
16746            chip.querySelector('.info-chip-label').textContent = s.l;
16747            inner.classList.remove('fading');
16748          }, 720);
16749        }
16750        setInterval(function() {
16751          chips.forEach(function(chip, i) { advance(i); });
16752        }, 6000);
16753      })();
16754      (function cardLiveData() {
16755        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
16756          var el = document.getElementById('acp-scan-stat');
16757          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
16758        }).catch(function(){});
16759        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
16760          var el = document.getElementById('acp-test-stat');
16761          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
16762        }).catch(function(){});
16763        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
16764          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
16765          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
16766          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
16767          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
16768          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
16769          var stat = document.getElementById('acp-int-stat');
16770          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
16771        }).catch(function(){});
16772        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
16773          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
16774        }).catch(function(){});
16775      })();
16776    })();
16777  </script>
16778  <script nonce="{{ csp_nonce }}">
16779  (function(){
16780    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'}];
16781    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);});}
16782    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16783    function init(){
16784      var btn=document.getElementById('settings-btn');if(!btn)return;
16785      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16786      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>';
16787      document.body.appendChild(m);
16788      var g=document.getElementById('scheme-grid');
16789      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);});
16790      var cl=document.getElementById('settings-close');
16791      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);
16792      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');});
16793      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16794      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16795    }
16796    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16797  }());
16798  </script>
16799  <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>
16800</body>
16801</html>
16802"##,
16803    ext = "html"
16804)]
16805struct SplashTemplate {
16806    csp_nonce: String,
16807    server_mode: bool,
16808    lan_ip: Option<String>,
16809    port: u16,
16810    version: &'static str,
16811    has_api_key: bool,
16812}
16813
16814// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
16815
16816#[derive(Template)]
16817#[template(
16818    source = r##"
16819<!doctype html>
16820<html lang="en">
16821<head>
16822  <meta charset="utf-8">
16823  <meta name="viewport" content="width=device-width, initial-scale=1">
16824  <title>OxideSLOC — Start a Scan</title>
16825  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16826  <style nonce="{{ csp_nonce }}">
16827    :root {
16828      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
16829      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16830      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16831      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16832      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
16833    }
16834    body.dark-theme {
16835      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
16836      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
16837    }
16838    *{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;}
16839    .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);}
16840    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16841    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
16842    .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));}
16843    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
16844    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
16845    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
16846    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16847    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16848    @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; } }
16849    .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;}
16850    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16851    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
16852    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16853    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16854    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16855    .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;}
16856    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16857    .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);}
16858    .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;}
16859    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16860    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16861    .settings-modal-body{padding:14px 16px 16px;}
16862    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16863    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16864    .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;}
16865    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16866    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16867    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16868    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16869    .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;}
16870    .tz-select:focus{border-color:var(--oxide);}
16871    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
16872    .page-header{text-align:center;margin-bottom:16px;}
16873    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
16874    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
16875    /* Cards */
16876    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
16877    .option-card-wrap{position:relative;}
16878    .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;}
16879    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
16880    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
16881    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
16882    .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;}
16883    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
16884    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
16885    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
16886    .card-top-row{display:flex;align-items:center;gap:20px;}
16887    /* Two-column layout inside each card */
16888    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
16889    .card-left{display:flex;align-items:flex-start;min-width:0;}
16890    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
16891    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
16892    .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);}
16893    .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);}
16894    .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);}
16895    .card-text{min-width:0;}
16896    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
16897    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
16898    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
16899    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
16900    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
16901    /* Right CTA column */
16902    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
16903    .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;}
16904    /* Re-scan count badge */
16905    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
16906    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
16907    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
16908    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
16909    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
16910    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
16911    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
16912    body.dark-theme .btn-secondary{color:var(--oxide);}
16913    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
16914    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
16915    /* File input overlay — must be full-width so it aligns with other card-right buttons */
16916    .file-input-wrap{position:relative;width:100%;}
16917    .file-input-wrap .btn{width:100%;}
16918    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
16919    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16920    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16921    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16922    .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;}
16923    @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));}}
16924    /* Recent list (card 3 — full-width section below header) */
16925    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
16926    .recent-list{display:flex;flex-direction:column;gap:8px;}
16927    .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;}
16928    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
16929    .recent-item-info{flex:1;min-width:0;}
16930    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
16931    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
16932    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
16933    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
16934    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16935    .site-footer a{color:var(--muted);}
16936    @media(max-width:680px){
16937      .card-body{grid-template-columns:1fr;}
16938      .card-right{flex-direction:row;flex-wrap:wrap;}
16939      .btn{flex:1;}
16940    }
16941    .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;}
16942    .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;}
16943    .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;}
16944  </style>
16945</head>
16946<body>
16947  <div class="background-watermarks" aria-hidden="true">
16948    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16949    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16950    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16951    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16952    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16953    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16954    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16955  </div>
16956  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16957  <div class="top-nav">
16958    <div class="top-nav-inner">
16959      <a class="brand" href="/">
16960        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16961        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
16962      </a>
16963      <div class="nav-right">
16964        <a class="nav-pill" href="/">Home</a>
16965        <div class="nav-dropdown">
16966          <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>
16967          <div class="nav-dropdown-menu">
16968            <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>
16969          </div>
16970        </div>
16971        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16972        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16973        <div class="nav-dropdown">
16974          <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>
16975          <div class="nav-dropdown-menu">
16976            <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>
16977          </div>
16978        </div>
16979        <div class="server-status-wrap" id="server-status-wrap">
16980          <div class="nav-pill server-online-pill" id="server-status-pill">
16981            <span class="status-dot" id="status-dot"></span>
16982            <span id="server-status-label">Server</span>
16983            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16984          </div>
16985          <div class="server-status-tip">
16986            OxideSLOC is running — accessible on your network.
16987            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16988          </div>
16989        </div>
16990        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16991          <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>
16992        </button>
16993        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16994          <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>
16995          <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>
16996        </button>
16997      </div>
16998    </div>
16999  </div>
17000
17001  <div class="page">
17002    <div class="page-header">
17003      <h1>How would you like to scan?</h1>
17004      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
17005    </div>
17006
17007    <div class="option-grid">
17008
17009      <!-- Option 1: New scan -->
17010      <div class="option-card-wrap">
17011        <div class="option-card">
17012        <div class="option-icon new-scan">
17013          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
17014        </div>
17015        <div class="card-body">
17016          <div class="card-left">
17017            <div class="card-text">
17018              <div class="option-title">Start a new scan</div>
17019              <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>
17020              <ul class="feature-list">
17021                <li>Live project scope preview before you run</li>
17022                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
17023                <li>HTML, PDF, and JSON output — your choice</li>
17024              </ul>
17025            </div>
17026          </div>
17027          <div class="card-right">
17028            <a class="btn btn-primary" href="/scan">
17029              Configure &amp; scan
17030              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
17031            </a>
17032            <p class="card-tip">Full 4-step setup · all options</p>
17033          </div>
17034        </div>
17035        </div>
17036      </div>
17037
17038      <!-- Option 2: Load from config file -->
17039      <div class="option-card-wrap">
17040        <div class="option-card">
17041        <div class="option-icon load-config">
17042          <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>
17043        </div>
17044        <div class="card-body">
17045          <div class="card-left">
17046            <div class="card-text">
17047              <div class="option-title">Load a saved config</div>
17048              <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>
17049              <ul class="feature-list">
17050                <li>All 15 settings restored from the file</li>
17051                <li>Fully editable — change path or output dir</li>
17052                <li>Works with any scan-config.json</li>
17053              </ul>
17054            </div>
17055          </div>
17056          <div class="card-right">
17057            <div class="file-input-wrap">
17058              <button class="btn btn-secondary" id="load-config-btn" type="button">
17059                <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>
17060                Choose config file
17061              </button>
17062              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
17063            </div>
17064            <p class="card-tip" id="config-file-name">Exported after every scan</p>
17065          </div>
17066        </div>
17067        </div>
17068      </div>
17069
17070      <!-- Option 3: Re-scan recent project -->
17071      <div class="option-card-wrap">
17072        <div class="option-card" id="recent-card">
17073        <div class="card-top-row">
17074          <div class="option-icon rescan">
17075            <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>
17076          </div>
17077          <div class="card-body">
17078            <div class="card-left">
17079              <div class="card-text">
17080                <div class="option-title">Re-scan a recent project</div>
17081                <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>
17082                <ul class="feature-list">
17083                  <li>All 15+ settings restored from the saved config</li>
17084                  <li>Path and output dir are editable before running</li>
17085                  <li>Only scans with a saved config appear here</li>
17086                </ul>
17087              </div>
17088            </div>
17089            <div class="card-right">
17090              <div class="rescan-count-box">
17091                <div class="rescan-count-num" id="rescan-count-num">—</div>
17092                <div class="rescan-count-label">saved configs</div>
17093              </div>
17094              <a class="btn btn-secondary" href="/view-reports">
17095                <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>
17096                View all runs
17097              </a>
17098              <p class="card-tip">Opens run history</p>
17099            </div>
17100          </div>
17101        </div>
17102        <div class="section-divider"></div>
17103        <div class="recent-list" id="recent-list">
17104          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
17105        </div>
17106        </div>
17107      </div>
17108
17109    </div>
17110  </div>
17111
17112  <footer class="site-footer">
17113    local code analysis - metrics, history and reports
17114    &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>
17115    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17116    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17117    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17118    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17119  </footer>
17120
17121  <script nonce="{{ csp_nonce }}">
17122    (function () {
17123      var storageKey = 'oxide-sloc-theme';
17124      var body = document.body;
17125      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17126      var toggle = document.getElementById('theme-toggle');
17127      if (toggle) toggle.addEventListener('click', function () {
17128        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17129        body.classList.toggle('dark-theme', next === 'dark');
17130        try { localStorage.setItem(storageKey, next); } catch(e) {}
17131      });
17132
17133      (function randomizeWatermarks() {
17134        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17135        if (!wms.length) return;
17136        var placed = [];
17137        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; }
17138        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]; }
17139        var half = Math.floor(wms.length / 2);
17140        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; });
17141      })();
17142      (function spawnCodeParticles() {
17143        var container = document.getElementById('code-particles');
17144        if (!container) return;
17145        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'];
17146        var count = 38;
17147        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); }
17148      })();
17149      // Recent scans data injected from server
17150      var recentScans = {{ recent_scans_json|safe }};
17151
17152      function configToParams(cfg) {
17153        var p = new URLSearchParams();
17154        p.set('prefilled', '1');
17155        if (cfg.path) p.set('path', cfg.path);
17156        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
17157        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
17158        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
17159        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
17160        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
17161        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
17162        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
17163        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
17164        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
17165        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
17166        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
17167        if (cfg.report_title) p.set('report_title', cfg.report_title);
17168        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
17169        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
17170        return p;
17171      }
17172
17173      // Build recent scan list (capped at 3 visible entries)
17174      var list = document.getElementById('recent-list');
17175      var noNote = document.getElementById('no-recent-note');
17176      var hasAny = false;
17177      var MAX_RECENT = 3;
17178      if (Array.isArray(recentScans)) {
17179        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
17180        var shown = 0;
17181        validEntries.forEach(function (entry) {
17182          if (shown >= MAX_RECENT) return;
17183          shown++;
17184          hasAny = true;
17185          var item = document.createElement('div');
17186          item.className = 'recent-item';
17187          item.title = 'Restore all settings and open wizard';
17188          item.innerHTML =
17189            '<div class="recent-item-info">' +
17190              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
17191              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
17192            '</div>' +
17193            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
17194          item.addEventListener('click', function () {
17195            var params = configToParams(entry.config);
17196            window.location.href = '/scan?' + params.toString();
17197          });
17198          list.appendChild(item);
17199        });
17200        if (validEntries.length > MAX_RECENT) {
17201          var moreEl = document.createElement('div');
17202          moreEl.className = 'recent-more-link';
17203          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
17204          list.appendChild(moreEl);
17205        }
17206      }
17207      if (hasAny && noNote) noNote.style.display = 'none';
17208      // Update count badge
17209      var countEl = document.getElementById('rescan-count-num');
17210      if (countEl) {
17211        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
17212        countEl.textContent = total > 0 ? total : '0';
17213      }
17214
17215      // Config file loader
17216      var fileInput = document.getElementById('config-file-input');
17217      var fileName = document.getElementById('config-file-name');
17218      var loadBtn = document.getElementById('load-config-btn');
17219      // Wire the visible button to open the hidden file picker.
17220      if (loadBtn && fileInput) {
17221        loadBtn.addEventListener('click', function () { fileInput.click(); });
17222      }
17223      if (fileInput) {
17224        fileInput.addEventListener('change', function () {
17225          var file = fileInput.files && fileInput.files[0];
17226          if (!file) return;
17227          if (fileName) fileName.textContent = '✓ ' + file.name;
17228          var reader = new FileReader();
17229          reader.onload = function (e) {
17230            try {
17231              var cfg = JSON.parse(e.target.result);
17232              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
17233              var params = configToParams(cfg);
17234              window.location.href = '/scan?' + params.toString();
17235            } catch (err) {
17236              alert('Could not parse config file: ' + err.message);
17237            }
17238          };
17239          reader.readAsText(file);
17240        });
17241      }
17242
17243      function escHtml(s) {
17244        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
17245      }
17246    })();
17247  </script>
17248  <script nonce="{{ csp_nonce }}">
17249  (function(){
17250    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'}];
17251    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);});}
17252    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17253    function init(){
17254      var btn=document.getElementById('settings-btn');if(!btn)return;
17255      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17256      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>';
17257      document.body.appendChild(m);
17258      var g=document.getElementById('scheme-grid');
17259      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);});
17260      var cl=document.getElementById('settings-close');
17261      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);
17262      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');});
17263      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17264      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17265    }
17266    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17267  }());
17268  </script>
17269  <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>
17270</body>
17271</html>
17272"##,
17273    ext = "html"
17274)]
17275struct ScanSetupTemplate {
17276    version: &'static str,
17277    recent_scans_json: String,
17278    csp_nonce: String,
17279}
17280
17281#[derive(Template)]
17282#[template(
17283    source = r##"
17284<!doctype html>
17285<html lang="en">
17286<head>
17287  <meta charset="utf-8">
17288  <meta name="viewport" content="width=device-width, initial-scale=1">
17289  <title>OxideSLOC | {{ report_title }} | Report</title>
17290  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17291  <style nonce="{{ csp_nonce }}">
17292    :root {
17293      --radius: 18px;
17294      --bg: #f5efe8;
17295      --surface: rgba(255,255,255,0.82);
17296      --surface-2: #fbf7f2;
17297      --surface-3: #efe6dc;
17298      --line: #e6d0bf;
17299      --line-strong: #dcb89f;
17300      --text: #43342d;
17301      --muted: #7b675b;
17302      --muted-2: #a08777;
17303      --nav: #b85d33;
17304      --nav-2: #7a371b;
17305      --accent: #6f9bff;
17306      --accent-2: #4a78ee;
17307      --oxide: #d37a4c;
17308      --oxide-2: #b35428;
17309      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
17310      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
17311      --success-bg: #e8f5ed;
17312      --success-text: #1a8f47;
17313      --info-bg: #eef3ff;
17314      --info-text: #4467d8;
17315    }
17316
17317    body.dark-theme {
17318      --bg: #1b1511;
17319      --surface: #261c17;
17320      --surface-2: #2d221d;
17321      --surface-3: #372922;
17322      --line: #524238;
17323      --line-strong: #6c5649;
17324      --text: #f5ece6;
17325      --muted: #c7b7aa;
17326      --muted-2: #aa9485;
17327      --nav: #b85d33;
17328      --nav-2: #7a371b;
17329      --accent: #6f9bff;
17330      --accent-2: #4a78ee;
17331      --oxide: #d37a4c;
17332      --oxide-2: #b35428;
17333      --shadow: 0 18px 42px rgba(0,0,0,0.28);
17334      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
17335      --success-bg: #163927;
17336      --success-text: #8fe2a8;
17337      --info-bg: #1c2847;
17338      --info-text: #a9c1ff;
17339    }
17340
17341    * { box-sizing: border-box; }
17342    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); }
17343    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17344    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17345    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17346    .top-nav, .page { position: relative; z-index: 2; }
17347    .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); }
17348    .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; }
17349    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17350    .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)); }
17351    .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; }
17352    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17353    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17354    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17355    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17356    .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; }
17357    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17358    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17359    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17360    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17361    @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; } }
17362    .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; }
17363    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17364    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17365    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17366    .theme-toggle .icon-sun { display:none; }
17367    body.dark-theme .theme-toggle .icon-sun { display:block; }
17368    body.dark-theme .theme-toggle .icon-moon { display:none; }
17369    .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;}
17370    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17371    .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);}
17372    .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;}
17373    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17374    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17375    .settings-modal-body{padding:14px 16px 16px;}
17376    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17377    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17378    .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;}
17379    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17380    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17381    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17382    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17383    .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;}
17384    .tz-select:focus{border-color:var(--oxide);}
17385    .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; }
17386    .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;}
17387    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
17388    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
17389    .hero, .panel { padding: 22px; }
17390    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
17391    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17392    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
17393    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
17394    .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; }
17395    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
17396    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
17397    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
17398    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
17399    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
17400    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
17401    .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; }
17402    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
17403    .delta-card-val { font-size:16px; font-weight:800; }
17404    .delta-card-val.pos { color:#1e7e34; }
17405    .delta-card-val.neg { color:var(--neg); }
17406    .delta-card-val.mod { color:#b35428; }
17407    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
17408    .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; }
17409    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17410    .delta-card-inline:hover .delta-card-tip { opacity:1; }
17411    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
17412    .compare-ts { font-size:13px; color:var(--muted); }
17413    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
17414    .compare-arrow { color: var(--muted); }
17415    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
17416    .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; }
17417    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
17418    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
17419    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
17420    .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; }
17421    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
17422    .run-mgmt-card .action-buttons { justify-content:center; }
17423    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
17424    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
17425    .button, .copy-button {
17426      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;
17427    }
17428    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
17429    @keyframes spin { to { transform: rotate(360deg); } }
17430    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
17431    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
17432    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
17433    .path-item strong { display: block; margin-bottom: 6px; }
17434    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
17435    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
17436    .path-subitem { flex: 1; }
17437    .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); }
17438    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); }
17439    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
17440    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
17441    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
17442    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
17443    th { color: var(--muted); font-weight: 700; }
17444    tr:last-child td { border-bottom: none; }
17445    #subm-tbl col:nth-child(1){width:15%;}
17446    #subm-tbl col:nth-child(2){width:31%;}
17447    #subm-tbl col:nth-child(3){width:9%;}
17448    #subm-tbl col:nth-child(4){width:9%;}
17449    #subm-tbl col:nth-child(5){width:9%;}
17450    #subm-tbl col:nth-child(6){width:9%;}
17451    #subm-tbl col:nth-child(7){width:9%;}
17452    #subm-tbl col:nth-child(8){width:9%;}
17453    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
17454    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
17455    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
17456    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
17457    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
17458    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
17459    .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; }
17460    .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; }
17461    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
17462    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
17463    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
17464    .muted { color: var(--muted); }
17465    /* Run-ID chip row (mirrors HTML report) */
17466    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
17467    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
17468    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
17469    .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; }
17470    .run-id-chip[data-copy] { cursor:pointer; }
17471    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
17472    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
17473    .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; }
17474    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
17475    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17476    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
17477    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
17478    a.commit-link-value { color:inherit; text-decoration:none; }
17479    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
17480    .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; }
17481    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17482    .run-id-chip:hover .chip-tooltip { opacity:1; }
17483    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
17484    .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; }
17485    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
17486    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
17487    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
17488    /* Meta chips row */
17489    .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%; }
17490    .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; }
17491    .meta-chip:last-child { border-right:none; }
17492    .meta-chip b { color:var(--text); font-weight:700; }
17493    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17494    .site-footer a{color:var(--muted);}
17495    .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; }
17496    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
17497    .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; }
17498    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
17499    /* Stat chips (matches HTML report) */
17500    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
17501    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
17502    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
17503    .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; }
17504    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
17505    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
17506    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
17507    .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; }
17508    .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; }
17509    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
17510    .stat-chip:hover .stat-chip-tip { opacity:1; }
17511    /* Submodule panel */
17512    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
17513    /* Metrics tables stack */
17514    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
17515    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
17516    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
17517    .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)); }
17518    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
17519    /* Metrics table */
17520    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
17521    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
17522    .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; }
17523    .metrics-table thead th:not(:first-child) { text-align: right; }
17524    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
17525    .metrics-table tbody tr:last-child td { border-bottom: none; }
17526    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
17527    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
17528    .metrics-table tbody tr:hover td { background: var(--surface-2); }
17529    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
17530    .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; }
17531    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
17532    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
17533    .mt-val-pos { color: var(--pos); font-weight: 700; }
17534    .mt-val-neg { color: var(--neg); font-weight: 700; }
17535    .mt-val-zero { color: var(--muted); }
17536    .mt-val-mod { color: var(--oxide-2); }
17537    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
17538    @media (max-width: 1180px) {
17539      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
17540      .nav-project-slot, .nav-status { justify-content:flex-start; }
17541      .hero-top { flex-direction: column; }
17542      .run-mgmt-strip { flex-direction: column; }
17543    }
17544    .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;}
17545    @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));}}
17546    .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;}
17547    /* ── Result-page chart controls ─────────────────────────────────────────── */
17548    .r-chart-section{margin-bottom:24px;}
17549    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
17550    .section-pair > .panel{flex-shrink:0;}
17551    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
17552    .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;}
17553    .r-chart-select:focus{border-color:var(--accent);}
17554    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
17555    .r-chart-container svg{display:block;width:100%;height:auto;}
17556    .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;}
17557    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
17558    .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;}
17559    .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);}
17560    .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;}
17561    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
17562    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
17563    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
17564    .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;}
17565    .r-chart-modal-close:hover{opacity:.7;}
17566    body.dark-theme .r-chart-modal{background:var(--surface);}
17567    .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;}
17568    .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);}
17569    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
17570    .lang-bar-row:hover{transform:translateY(-2px);}
17571    .lang-bar-row .rchit:hover{filter:none;transform:none;}
17572    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
17573    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
17574    .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;}
17575    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
17576    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
17577    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
17578    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
17579    #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;}
17580    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
17581    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
17582    .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;}
17583    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
17584    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
17585    .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;}
17586    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
17587    .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;}
17588    .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;}
17589    body.has-report-banner .top-nav{top:27px;}
17590    body.has-report-banner{padding-bottom:27px;}
17591  </style>
17592</head>
17593<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
17594  <div class="background-watermarks" aria-hidden="true">
17595    <img src="/images/logo/logo-text.png" alt="" />
17596    <img src="/images/logo/logo-text.png" alt="" />
17597    <img src="/images/logo/logo-text.png" alt="" />
17598    <img src="/images/logo/logo-text.png" alt="" />
17599    <img src="/images/logo/logo-text.png" alt="" />
17600    <img src="/images/logo/logo-text.png" alt="" />
17601    <img src="/images/logo/logo-text.png" alt="" />
17602    <img src="/images/logo/logo-text.png" alt="" />
17603    <img src="/images/logo/logo-text.png" alt="" />
17604    <img src="/images/logo/logo-text.png" alt="" />
17605    <img src="/images/logo/logo-text.png" alt="" />
17606    <img src="/images/logo/logo-text.png" alt="" />
17607    <img src="/images/logo/logo-text.png" alt="" />
17608    <img src="/images/logo/logo-text.png" alt="" />
17609  </div>
17610  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17611  {% if let Some(banner) = report_header_footer %}
17612  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
17613  {% endif %}
17614  <div class="top-nav">
17615    <div class="top-nav-inner">
17616      <a class="brand" href="/">
17617        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17618        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
17619      </a>
17620      <div class="nav-project-slot">
17621        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
17622      </div>
17623      <div class="nav-status">
17624        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
17625        <div class="nav-dropdown">
17626          <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>
17627          <div class="nav-dropdown-menu">
17628            <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>
17629          </div>
17630        </div>
17631        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
17632        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17633        <div class="nav-dropdown">
17634          <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>
17635          <div class="nav-dropdown-menu">
17636            <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>
17637          </div>
17638        </div>
17639        <div class="server-status-wrap" id="server-status-wrap">
17640          <div class="nav-pill server-online-pill" id="server-status-pill">
17641            <span class="status-dot" id="status-dot"></span>
17642            <span id="server-status-label">Server</span>
17643            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17644          </div>
17645          <div class="server-status-tip">
17646            OxideSLOC is running — accessible on your network.
17647            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17648          </div>
17649        </div>
17650        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17651          <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>
17652        </button>
17653        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17654          <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>
17655          <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>
17656        </button>
17657      </div>
17658    </div>
17659  </div>
17660
17661  <div class="page">
17662    <section class="hero">
17663      <div class="hero-top">
17664        <div>
17665          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
17666            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
17667            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
17668            <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>
17669          </div>
17670        </div>
17671        <div class="hero-quick-actions">
17672          {% if server_mode %}
17673          <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>
17674          {% else %}
17675          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
17676          {% endif %}
17677          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
17678          {% if !server_mode %}
17679          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
17680          {% endif %}
17681          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
17682          <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>
17683        </div>
17684      </div>
17685
17686      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
17687      <div class="run-id-row">
17688        <span class="run-id-chip" data-copy="{{ run_id }}">
17689          <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>
17690          <span class="run-id-chip-value">{{ run_id }}</span>
17691          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
17692        </span>
17693        {% match git_commit_long %}
17694          {% when Some with (long_sha) %}
17695          {% match git_commit_url %}
17696            {% when Some with (commit_url) %}
17697            <span class="run-id-chip" data-copy="{{ long_sha }}">
17698              <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>
17699              <a href="{{ commit_url }}" target="_blank" rel="noopener" class="run-id-chip-value commit-link-value" onclick="event.stopPropagation()">{{ long_sha }}</a>
17700              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
17701            </span>
17702            {% when None %}
17703            <span class="run-id-chip" data-copy="{{ long_sha }}">
17704              <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>
17705              <span class="run-id-chip-value">{{ long_sha }}</span>
17706              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
17707            </span>
17708          {% endmatch %}
17709          {% when None %}
17710          <span class="run-id-chip muted-chip">
17711            <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>
17712            <span class="run-id-chip-value">Not detected</span>
17713            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
17714          </span>
17715        {% endmatch %}
17716        {% match git_branch %}
17717          {% when Some with (branch) %}
17718          <span class="run-id-chip">
17719            <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>
17720            <span class="run-id-chip-value">{{ branch }}</span>
17721            <span class="chip-tooltip">Git branch active at scan time</span>
17722          </span>
17723          {% when None %}
17724          <span class="run-id-chip muted-chip">
17725            <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>
17726            <span class="run-id-chip-value">Not detected</span>
17727            <span class="chip-tooltip">No Git branch was found for this scan</span>
17728          </span>
17729        {% endmatch %}
17730        {% match git_author %}
17731          {% when Some with (author) %}
17732          <span class="run-id-chip" data-author="{{ author }}">
17733            <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>
17734            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
17735            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
17736          </span>
17737          {% when None %}
17738          <span class="run-id-chip muted-chip">
17739            <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>
17740            <span class="run-id-chip-value">Not detected</span>
17741            <span class="chip-tooltip">No commit author was found for this scan</span>
17742          </span>
17743        {% endmatch %}
17744      </div>
17745
17746      <!-- Scan metadata row -->
17747      <div class="meta">
17748        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
17749        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
17750        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
17751        <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
17752        <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
17753      </div>
17754
17755      <!-- 12 summary stat chips -->
17756      <div class="summary-strip">
17757        <div class="stat-chip" data-raw="{{ physical_lines }}">
17758          <div class="stat-chip-label">Physical lines</div>
17759          <div class="stat-chip-val">{{ physical_lines }}</div>
17760          <div class="stat-chip-exact"></div>
17761          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
17762        </div>
17763        <div class="stat-chip" data-raw="{{ code_lines }}">
17764          <div class="stat-chip-label">Code</div>
17765          <div class="stat-chip-val">{{ code_lines }}</div>
17766          <div class="stat-chip-exact"></div>
17767          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
17768        </div>
17769        <div class="stat-chip" data-raw="{{ comment_lines }}">
17770          <div class="stat-chip-label">Comments</div>
17771          <div class="stat-chip-val">{{ comment_lines }}</div>
17772          <div class="stat-chip-exact"></div>
17773          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
17774        </div>
17775        <div class="stat-chip" data-raw="{{ blank_lines }}">
17776          <div class="stat-chip-label">Blank</div>
17777          <div class="stat-chip-val">{{ blank_lines }}</div>
17778          <div class="stat-chip-exact"></div>
17779          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
17780        </div>
17781        <div class="stat-chip" data-raw="{{ mixed_lines }}">
17782          <div class="stat-chip-label">Mixed separate</div>
17783          <div class="stat-chip-val">{{ mixed_lines }}</div>
17784          <div class="stat-chip-exact"></div>
17785          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
17786        </div>
17787        <div class="stat-chip" data-raw="{{ functions }}">
17788          <div class="stat-chip-label">Functions</div>
17789          <div class="stat-chip-val">{{ functions }}</div>
17790          <div class="stat-chip-exact"></div>
17791          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
17792        </div>
17793        <div class="stat-chip" data-raw="{{ classes }}">
17794          <div class="stat-chip-label">Classes / Types</div>
17795          <div class="stat-chip-val">{{ classes }}</div>
17796          <div class="stat-chip-exact"></div>
17797          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
17798        </div>
17799        <div class="stat-chip" data-raw="{{ variables }}">
17800          <div class="stat-chip-label">Variables</div>
17801          <div class="stat-chip-val">{{ variables }}</div>
17802          <div class="stat-chip-exact"></div>
17803          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
17804        </div>
17805        <div class="stat-chip" data-raw="{{ imports }}">
17806          <div class="stat-chip-label">Imports</div>
17807          <div class="stat-chip-val">{{ imports }}</div>
17808          <div class="stat-chip-exact"></div>
17809          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
17810        </div>
17811        <div class="stat-chip" data-raw="{{ test_count }}">
17812          <div class="stat-chip-label">Tests</div>
17813          <div class="stat-chip-val">{{ test_count }}</div>
17814          <div class="stat-chip-exact"></div>
17815          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
17816        </div>
17817        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
17818          <div class="stat-chip-label">Code density</div>
17819          <div class="stat-chip-val stat-chip-density-val">—</div>
17820          <div class="stat-chip-exact"></div>
17821          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
17822        </div>
17823        <div class="stat-chip" data-raw="{{ files_analyzed }}">
17824          <div class="stat-chip-label">Files analyzed</div>
17825          <div class="stat-chip-val">{{ files_analyzed }}</div>
17826          <div class="stat-chip-exact"></div>
17827          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
17828        </div>
17829      </div>
17830
17831      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
17832      <div class="compare-banner">
17833        <div class="compare-banner-body">
17834          <div class="compare-banner-meta">
17835            <span class="compare-label">Previous scan</span>
17836            <span class="compare-ts">{{ prev_ts }}</span>
17837            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
17838            {% if let Some(prev_code) = prev_run_code_lines %}
17839            <div class="compare-banner-stats" style="margin-top:4px;">
17840              <span>Code before: <strong>{{ prev_code }}</strong></span>
17841              <span class="compare-arrow">→</span>
17842              <span>Code now: <strong>{{ code_lines }}</strong></span>
17843              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
17844              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
17845            </div>
17846            {% endif %}
17847          </div>
17848          {% if delta_lines_added.is_some() %}
17849          <div class="delta-cards-inline">
17850            <div class="delta-card-inline">
17851              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
17852              <div class="delta-card-lbl">lines added</div>
17853              <div class="delta-card-tip">Code lines added since the previous scan</div>
17854            </div>
17855            <div class="delta-card-inline">
17856              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
17857              <div class="delta-card-lbl">lines removed</div>
17858              <div class="delta-card-tip">Code lines removed since the previous scan</div>
17859            </div>
17860            <div class="delta-card-inline">
17861              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
17862              <div class="delta-card-lbl">unmodified lines</div>
17863              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
17864            </div>
17865            <div class="delta-card-inline">
17866              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
17867              <div class="delta-card-lbl">files modified</div>
17868              <div class="delta-card-tip">Files with at least one line changed</div>
17869            </div>
17870            <div class="delta-card-inline">
17871              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
17872              <div class="delta-card-lbl">files added</div>
17873              <div class="delta-card-tip">New files added since the previous scan</div>
17874            </div>
17875            <div class="delta-card-inline">
17876              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
17877              <div class="delta-card-lbl">files removed</div>
17878              <div class="delta-card-tip">Files deleted since the previous scan</div>
17879            </div>
17880            <div class="delta-card-inline">
17881              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
17882              <div class="delta-card-lbl">files unchanged</div>
17883              <div class="delta-card-tip">Files with no changes since the previous scan</div>
17884            </div>
17885          </div>
17886          {% else %}
17887          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
17888            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
17889          </p>
17890          {% endif %}
17891          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
17892        </div>
17893      </div>
17894      {% endif %}{% endif %}
17895
17896      <div class="action-grid">
17897        <div class="action-card">
17898          <h3>HTML report</h3>
17899          <div class="action-buttons">
17900            {% match html_url %}
17901              {% when Some with (url) %}
17902                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
17903              {% when None %}{% endmatch %}
17904            {% match html_download_url %}
17905              {% when Some with (url) %}
17906                <a class="button secondary" href="{{ url }}">Download HTML</a>
17907              {% when None %}{% endmatch %}
17908            {% match html_path %}
17909              {% when Some with (_path) %}{% when None %}{% endmatch %}
17910            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
17911          </div>
17912        </div>
17913        <div class="action-card">
17914          <h3>PDF report</h3>
17915          <div class="action-buttons">
17916            {% match pdf_url %}
17917              {% when Some with (url) %}
17918                {% if pdf_generating %}
17919                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
17920                    <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>
17921                    Generating PDF…
17922                  </button>
17923                {% else %}
17924                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
17925                {% endif %}
17926              {% when None %}
17927                {% match html_url %}
17928                  {% when Some with (_hurl) %}
17929                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
17930                    <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>
17931                  {% when None %}
17932                    <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;">
17933                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
17934                    </p>
17935                {% endmatch %}
17936            {% endmatch %}
17937            {% match pdf_download_url %}
17938              {% when Some with (url) %}
17939                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
17940              {% when None %}{% endmatch %}
17941            {% match pdf_url %}
17942              {% when Some with (_) %}
17943                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
17944              {% when None %}{% endmatch %}
17945          </div>
17946        </div>
17947        <div class="action-card">
17948          <h3>JSON result</h3>
17949          <div class="action-buttons">
17950            {% match json_url %}
17951              {% when Some with (url) %}
17952                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
17953              {% when None %}{% endmatch %}
17954            {% match json_download_url %}
17955              {% when Some with (url) %}
17956                <a class="button secondary" href="{{ url }}">Download JSON</a>
17957              {% when None %}{% endmatch %}
17958            {% match json_path %}
17959              {% when Some with (_path) %}
17960                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
17961              {% when None %}
17962                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
17963              {% endmatch %}
17964          </div>
17965        </div>
17966        <div class="action-card">
17967          <h3>Scan config</h3>
17968          <div class="action-buttons">
17969            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
17970            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
17971            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
17972          </div>
17973        </div>
17974        {% if confluence_configured %}
17975        <div class="action-card" id="confluenceCard">
17976          <h3>Confluence</h3>
17977          <div class="action-buttons">
17978            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
17979            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
17980          </div>
17981          <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>
17982        </div>
17983        {% endif %}
17984      </div>
17985      {% if confluence_configured %}
17986      <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;">
17987        <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);">
17988          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
17989          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
17990          <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;">
17991          <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>
17992          <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;">
17993          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
17994          <div style="display:flex;gap:10px;justify-content:flex-end;">
17995            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
17996            <button class="button" id="confSubmitBtn" type="button">Post</button>
17997          </div>
17998        </div>
17999      </div>
18000      {% endif %}
18001      <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;">
18002        <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);">
18003          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
18004          <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>
18005          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
18006          <div style="display:flex;gap:18px;justify-content:flex-end;">
18007            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
18008            <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>
18009          </div>
18010        </div>
18011      </div>
18012      {% if !submodule_rows.is_empty() %}
18013      <div class="submodule-panel">
18014        <div class="toolbar-row">
18015          <div>
18016            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
18017            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
18018          </div>
18019          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
18020        </div>
18021        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
18022        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
18023          <colgroup><col style="width:15%"><col style="width:31%"><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>
18024          <thead>
18025            <tr>
18026              <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>
18027              <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>
18028              <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>
18029              <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>
18030              <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>
18031              <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>
18032              <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>
18033              <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>
18034            </tr>
18035          </thead>
18036          <tbody>
18037            {% for row in submodule_rows %}
18038            <tr>
18039              <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>
18040              <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>
18041              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
18042              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
18043              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
18044              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
18045              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
18046              <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>
18047            </tr>
18048            {% endfor %}
18049          </tbody>
18050        </table>
18051        </div>
18052      </div>
18053      {% endif %}
18054
18055      <div class="metrics-tables-stack">
18056
18057        <div class="metrics-table-wrap">
18058          <div class="metrics-table-title">Files</div>
18059          <table class="metrics-table">
18060            <thead>
18061              <tr>
18062                <th>Metric</th>
18063                <th>This Run</th>
18064                <th>Previous</th>
18065                <th>Change</th>
18066              </tr>
18067            </thead>
18068            <tbody>
18069              <tr>
18070                <td>Files analyzed</td>
18071                <td class="mt-val-large">{{ files_analyzed }}</td>
18072                <td>{{ prev_fa_str }}</td>
18073                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
18074              </tr>
18075              <tr>
18076                <td>Files skipped</td>
18077                <td>{{ files_skipped }}</td>
18078                <td>{{ prev_fs_str }}</td>
18079                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
18080              </tr>
18081              <tr>
18082                <td>Files modified</td>
18083                <td class="mt-val-na">—</td>
18084                <td class="mt-val-na">—</td>
18085                <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>
18086              </tr>
18087              <tr>
18088                <td>Files unchanged</td>
18089                <td class="mt-val-na">—</td>
18090                <td class="mt-val-na">—</td>
18091                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
18092              </tr>
18093            </tbody>
18094          </table>
18095        </div>
18096
18097        <div class="metrics-table-wrap">
18098          <div class="metrics-table-title">Line Counts</div>
18099          <table class="metrics-table">
18100            <thead>
18101              <tr>
18102                <th>Metric</th>
18103                <th>This Run</th>
18104                <th>Previous</th>
18105                <th>Change</th>
18106              </tr>
18107            </thead>
18108            <tbody>
18109              <tr>
18110                <td>Physical lines</td>
18111                <td class="mt-val-large">{{ physical_lines }}</td>
18112                <td>{{ prev_pl_str }}</td>
18113                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
18114              </tr>
18115              <tr>
18116                <td>Code lines</td>
18117                <td class="mt-val-large">{{ code_lines }}</td>
18118                <td>{{ prev_cl_str }}</td>
18119                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
18120              </tr>
18121              <tr>
18122                <td>Comment lines</td>
18123                <td>{{ comment_lines }}</td>
18124                <td>{{ prev_cml_str }}</td>
18125                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
18126              </tr>
18127              <tr>
18128                <td>Blank lines</td>
18129                <td>{{ blank_lines }}</td>
18130                <td>{{ prev_bl_str }}</td>
18131                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
18132              </tr>
18133              <tr>
18134                <td>Mixed (separate)</td>
18135                <td>{{ mixed_lines }}</td>
18136                <td class="mt-val-na">—</td>
18137                <td class="mt-val-na">—</td>
18138              </tr>
18139            </tbody>
18140          </table>
18141        </div>
18142
18143        <div class="metrics-tables-lower">
18144          <div class="metrics-table-wrap">
18145            <div class="metrics-table-title">Code Structure</div>
18146            <table class="metrics-table">
18147              <thead>
18148                <tr>
18149                  <th>Metric</th>
18150                  <th>This Run</th>
18151                </tr>
18152              </thead>
18153              <tbody>
18154                <tr>
18155                  <td>Functions</td>
18156                  <td>{{ functions }}</td>
18157                </tr>
18158                <tr>
18159                  <td>Classes / Types</td>
18160                  <td>{{ classes }}</td>
18161                </tr>
18162                <tr>
18163                  <td>Variables</td>
18164                  <td>{{ variables }}</td>
18165                </tr>
18166                <tr>
18167                  <td>Imports</td>
18168                  <td>{{ imports }}</td>
18169                </tr>
18170              </tbody>
18171            </table>
18172          </div>
18173
18174          <div class="metrics-table-wrap">
18175            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
18176            <table class="metrics-table">
18177              <thead>
18178                <tr>
18179                  <th>Metric</th>
18180                  <th>Change</th>
18181                </tr>
18182              </thead>
18183              <tbody>
18184                <tr>
18185                  <td>Lines added</td>
18186                  <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>
18187                </tr>
18188                <tr>
18189                  <td>Lines removed</td>
18190                  <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>
18191                </tr>
18192                <tr>
18193                  <td>Lines modified (net)</td>
18194                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
18195                </tr>
18196                <tr>
18197                  <td>Lines unmodified</td>
18198                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
18199                </tr>
18200              </tbody>
18201            </table>
18202          </div>
18203        </div>
18204
18205      </div>
18206
18207      <div class="path-list">
18208        <div class="path-item">
18209          <div class="path-item-label">Project path</div>
18210          <code>{{ project_path }}</code>
18211        </div>
18212        <div class="path-item">
18213          <div class="path-item-label">Git branch</div>
18214          {% if let Some(branch) = git_branch %}
18215          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
18216          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
18217          {% else %}
18218          <code style="color:var(--muted)">—</code>
18219          {% endif %}
18220        </div>
18221        <div class="path-item">
18222          <div class="path-item-label">Output folder</div>
18223          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
18224        </div>
18225        <div class="path-item">
18226          <div class="path-item-label">Run ID</div>
18227          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
18228            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
18229            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
18230          </div>
18231        </div>
18232      </div>
18233    </section>
18234
18235    <div class="section-pair">
18236    <section class="panel">
18237        <div class="toolbar-row">
18238          <div>
18239            <h2>Language breakdown</h2>
18240            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
18241          </div>
18242          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18243        </div>
18244        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
18245    </section>
18246
18247    <section class="panel r-chart-section">
18248      <div class="toolbar-row" style="margin-bottom:16px;">
18249        <div>
18250          <h2>Visualizations</h2>
18251          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
18252        </div>
18253      </div>
18254
18255      <div class="r-viz-grid">
18256        <div class="r-viz-card">
18257          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18258            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
18259            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18260          </div>
18261          <div class="r-chart-tab-bar">
18262            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
18263            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
18264          </div>
18265          <div class="r-chart-container" id="r-composition-chart"></div>
18266        </div>
18267        <div class="r-viz-card">
18268          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18269            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
18270            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18271          </div>
18272          <div class="r-chart-container" id="r-scatter-chart"></div>
18273        </div>
18274        {% if has_semantic_data %}
18275        <div class="r-viz-card">
18276          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
18277            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
18278            <select class="r-chart-select" id="r-semantic-metric">
18279              <option value="functions">Functions</option>
18280              <option value="classes">Classes</option>
18281              <option value="variables">Variables</option>
18282              <option value="imports">Imports</option>
18283              <option value="tests">Tests</option>
18284            </select>
18285            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18286          </div>
18287          <div class="r-chart-container" id="r-semantic-chart"></div>
18288        </div>
18289        {% endif %}
18290        <div class="r-viz-card">
18291          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18292            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
18293            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18294          </div>
18295          <div class="r-chart-container" id="r-density-chart"></div>
18296        </div>
18297        <div class="r-viz-card">
18298          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
18299            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
18300            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18301          </div>
18302          <div class="r-chart-container" id="r-avglines-chart"></div>
18303        </div>
18304        <div class="r-viz-card">
18305          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
18306            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
18307            <select class="r-chart-select" id="r-sub-metric">
18308              <option value="code">Code Lines</option>
18309              <option value="comment">Comments</option>
18310              <option value="blank">Blank Lines</option>
18311              <option value="physical">Physical Lines</option>
18312              <option value="files">Files</option>
18313            </select>
18314            <select class="r-chart-select" id="r-sub-sort">
18315              <option value="desc">Value ↓</option>
18316              <option value="asc">Value ↑</option>
18317              <option value="name">Name A→Z</option>
18318            </select>
18319            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
18320          </div>
18321          <div class="r-chart-container" id="r-submodule-chart"></div>
18322        </div>
18323      </div>
18324
18325    </section>
18326    </div>
18327
18328  </div>
18329
18330  <div id="r-tt" aria-hidden="true"></div>
18331
18332  <script nonce="{{ csp_nonce }}">
18333    (function () {
18334      var body = document.body;
18335      var themeToggle = document.getElementById('theme-toggle');
18336      var storageKey = 'oxide-sloc-theme';
18337
18338      function applyTheme(theme) {
18339        body.classList.toggle('dark-theme', theme === 'dark');
18340      }
18341
18342      function loadSavedTheme() {
18343        try {
18344          var saved = localStorage.getItem(storageKey);
18345          if (saved === 'dark' || saved === 'light') {
18346            applyTheme(saved);
18347          }
18348        } catch (e) {}
18349      }
18350
18351      if (themeToggle) {
18352        themeToggle.addEventListener('click', function () {
18353          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
18354          applyTheme(nextTheme);
18355          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
18356        });
18357      }
18358
18359      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
18360        button.addEventListener('click', function () {
18361          var value = button.getAttribute('data-copy-value') || '';
18362          if (!value) return;
18363          var originalText = button.textContent;
18364          function flashSuccess() {
18365            button.textContent = 'Copied!';
18366            setTimeout(function () { button.textContent = originalText; }, 1800);
18367          }
18368          function flashFail() {
18369            button.textContent = 'Copy failed';
18370            setTimeout(function () { button.textContent = originalText; }, 2000);
18371          }
18372          if (navigator.clipboard && navigator.clipboard.writeText) {
18373            navigator.clipboard.writeText(value).then(flashSuccess, function () {
18374              fallbackCopy(value, flashSuccess, flashFail);
18375            });
18376          } else {
18377            fallbackCopy(value, flashSuccess, flashFail);
18378          }
18379        });
18380      });
18381      function fallbackCopy(text, onSuccess, onFail) {
18382        try {
18383          var ta = document.createElement('textarea');
18384          ta.value = text;
18385          ta.style.position = 'fixed';
18386          ta.style.top = '-9999px';
18387          ta.style.left = '-9999px';
18388          document.body.appendChild(ta);
18389          ta.focus();
18390          ta.select();
18391          var ok = document.execCommand('copy');
18392          document.body.removeChild(ta);
18393          if (ok) { onSuccess(); } else { onFail(); }
18394        } catch (e) { onFail(); }
18395      }
18396
18397      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18398        btn.addEventListener('click', function () {
18399          var folder = btn.getAttribute('data-folder') || '';
18400          if (!folder) return;
18401          var orig = btn.textContent;
18402          fetch('/open-path?path=' + encodeURIComponent(folder))
18403            .then(function (r) { return r.json(); })
18404            .then(function (d) {
18405              if (d && d.server_mode_disabled) {
18406                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18407              } else if (d && d.ok) {
18408                btn.textContent = 'Opened!';
18409                setTimeout(function () { btn.textContent = orig; }, 1800);
18410              }
18411            })
18412            .catch(function () {
18413              btn.textContent = 'Failed';
18414              setTimeout(function () { btn.textContent = orig; }, 2000);
18415            });
18416        });
18417      });
18418
18419      loadSavedTheme();
18420
18421      // ── Compact number formatting for stat chips ──────────────────────────
18422      (function(){
18423        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();}
18424        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
18425          var raw=parseInt(chip.getAttribute('data-raw'),10);
18426          if(isNaN(raw))return;
18427          var valEl=chip.querySelector('.stat-chip-val');
18428          if(valEl)valEl.textContent=fmt(raw);
18429          var exactEl=chip.querySelector('.stat-chip-exact');
18430          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
18431        });
18432        // Code density chip
18433        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
18434          var code=parseInt(chip.getAttribute('data-code'),10);
18435          var phys=parseInt(chip.getAttribute('data-physical'),10);
18436          if(isNaN(code)||isNaN(phys)||phys===0)return;
18437          var pct=(code/phys*100).toFixed(1)+'%';
18438          var valEl=chip.querySelector('.stat-chip-val');
18439          if(valEl)valEl.textContent=pct;
18440        });
18441        // Populate author handle from data-author attribute
18442        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
18443          var author=chip.getAttribute('data-author');
18444          var el=chip.querySelector('.author-handle');
18445          if(el)el.textContent='/'+author.replace(/\s+/g,'');
18446        });
18447        // Click-to-copy on run-id-chip elements
18448        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
18449          chip.addEventListener('click',function(){
18450            var val=chip.getAttribute('data-copy');
18451            if(!val)return;
18452            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
18453            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);}
18454            chip.classList.add('chip-copied-flash');
18455            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
18456          });
18457        });
18458      })();
18459
18460      // ── Shared tooltip for all result-page charts ─────────────────────────
18461      var rTT=(function(){
18462        var el=document.getElementById('r-tt');
18463        if(!el)return{s:function(){},h:function(){},m:function(){}};
18464        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
18465        function hide(){el.style.display='none';}
18466        function move(e){
18467          var x=e.clientX+16,y=e.clientY-12;
18468          var r=el.getBoundingClientRect();
18469          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
18470          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
18471          el.style.left=x+'px';el.style.top=y+'px';
18472        }
18473        return{s:show,h:hide,m:move};
18474      })();
18475      window.rTT=rTT;
18476
18477      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
18478      (function(){
18479        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18480        document.addEventListener('mouseover',function(e){
18481          var t=e.target;
18482          while(t&&t.getAttribute){
18483            var l=t.getAttribute('data-ttl');
18484            if(l!==null){
18485              var v=t.getAttribute('data-ttv')||'';
18486              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
18487              return;
18488            }
18489            t=t.parentNode;
18490          }
18491        });
18492        document.addEventListener('mouseout',function(e){
18493          var t=e.target;
18494          while(t&&t.getAttribute){
18495            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
18496            t=t.parentNode;
18497          }
18498        });
18499        document.addEventListener('mousemove',function(e){
18500          var el=document.getElementById('r-tt');
18501          if(el&&el.style.display!=='none')rTT.m(e);
18502        });
18503      })();
18504
18505      // ── Language overview charts ───────────────────────────────────────────
18506      (function(){
18507        var D={{ lang_chart_json|safe }};
18508        if(!D||!D.length)return;
18509        var el=document.getElementById('result-lang-charts');
18510        if(!el)return;
18511        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18512        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
18513        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18514        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();}
18515        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18516        function px(n){return Math.round(n);}
18517        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+'"';}
18518        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
18519
18520        // Donut chart — height matches the stacked-bar chart so both panels align
18521        var rHb_d=28;
18522        var DH=Math.max(220,D.length*rHb_d+32);
18523        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
18524        var legX=204,DW=360;
18525        var legCount=D.length;
18526        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
18527        var legYStart=Math.round((DH-legCount*legSpacing)/2);
18528        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">';
18529        if(D.length===1){
18530          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
18531          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+'"/>';
18532        } else {
18533          var ang=-Math.PI/2;
18534          D.forEach(function(d,i){
18535            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18536            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
18537            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
18538            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
18539            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
18540            var pct=Math.round(d.code/tot*100);
18541            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"/>';
18542            ang+=sw;
18543          });
18544        }
18545        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
18546        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
18547        D.forEach(function(d,i){
18548          var ly=legYStart+i*legSpacing;
18549          var pctL=Math.round(d.code/tot*100);
18550          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
18551          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
18552          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
18553          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
18554          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
18555          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
18556          ds+='</g>';
18557        });
18558        ds+='</svg>';
18559
18560        // Horizontal stacked-bar chart — fills container width
18561        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
18562        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
18563        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">';
18564        D.forEach(function(d,i){
18565          var y=6+i*rHb,x=LW;
18566          var phys=d.physical||d.code+d.comments+d.blanks;
18567          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
18568          bs+='<g class="lang-bar-row">';
18569          bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
18570          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>';
18571          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;
18572          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;
18573          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"/>';
18574          bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
18575          bs+='</g>';
18576        });
18577        var ly=SH-14;
18578        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
18579        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
18580        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
18581        var totAll=totC+totCm+totBl||1;
18582        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
18583        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
18584        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
18585        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
18586        bs+='<g data-kind="code" style="cursor:pointer;">'
18587          +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
18588          +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
18589          +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
18590          +'</g>';
18591        bs+='<g data-kind="comment" style="cursor:pointer;">'
18592          +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
18593          +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
18594          +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
18595          +'</g>';
18596        bs+='<g data-kind="blank" style="cursor:pointer;">'
18597          +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
18598          +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
18599          +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
18600          +'</g>';
18601        bs+='</svg>';
18602        el.innerHTML='<div class="r-lang-overview">'+
18603          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
18604          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
18605        '</div>';
18606        function wireDonutLegend(svg){
18607          if(!svg)return;
18608          var paths=svg.querySelectorAll('path[data-lang]');
18609          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';}}}
18610          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
18611          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;}});
18612          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
18613        }
18614        function wireMixLegend(svg){
18615          if(!svg)return;
18616          var legGs=svg.querySelectorAll('g[data-kind]');
18617          var allRects=svg.querySelectorAll('rect[data-kind]');
18618          if(!legGs.length)return;
18619          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';}}
18620          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='';}}
18621          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]);}
18622        }
18623        wireDonutLegend(el.querySelector('svg'));
18624        wireMixLegend(el.querySelectorAll('svg')[1]);
18625
18626        // ── Language breakdown Full View expand ─────────────────────────────────
18627        var langOvBtn=document.getElementById('result-lang-overview-expand');
18628        if(langOvBtn){langOvBtn.addEventListener('click',function(){
18629          var src=document.getElementById('result-lang-charts');
18630          if(!src)return;
18631          var overlay=document.createElement('div');
18632          overlay.className='r-chart-modal-overlay';
18633          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>';
18634          document.body.appendChild(overlay);
18635          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18636          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18637          var wrap=document.getElementById('result-lang-overview-modal-wrap');
18638          if(wrap){
18639            wrap.innerHTML=src.innerHTML;
18640            var svgs=wrap.querySelectorAll('svg');
18641            for(var i=0;i<svgs.length;i++){
18642              svgs[i].removeAttribute('width');
18643              svgs[i].removeAttribute('height');
18644              svgs[i].style.cssText='display:block;width:100%;height:auto;';
18645            }
18646            var ov=wrap.querySelector('.r-lang-overview');
18647            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
18648            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
18649            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
18650            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
18651            wireDonutLegend(wrap.querySelector('svg'));
18652            wireMixLegend(wrap.querySelectorAll('svg')[1]);
18653            requestAnimationFrame(function(){
18654              var ss=wrap.querySelectorAll('svg');
18655              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%;';}}
18656            });
18657          }
18658        });}
18659      })();
18660
18661      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
18662      (function(){
18663        var LANG_D={{ lang_chart_json|safe }};
18664        var SCAT_D={{ scatter_chart_json|safe }};
18665        var SEM_D={{ semantic_chart_json|safe }};
18666        var SUB_D={{ submodule_chart_json|safe }};
18667        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
18668        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18669        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();}
18670        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18671        function px(n){return Math.round(n);}
18672        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+'"';}
18673
18674        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
18675        function renderCompositionInEl(el,mode,shOvr){
18676          if(!el||!LANG_D||!LANG_D.length)return;
18677          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
18678          var LW=110,SH=shOvr||224;
18679          var svgW=Math.max(320,el.offsetWidth||480);
18680          var BW=Math.max(120,svgW-LW-80);
18681          var legendH=24,topPad=4;
18682          var n=LANG_D.length||1;
18683          var rowTotal=Math.floor((SH-legendH-topPad)/n);
18684          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18685          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">';
18686          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
18687          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
18688          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
18689          var totAll2=totC2+totCm2+totBl2||1;
18690          if(mode==='pct'){
18691            LANG_D.forEach(function(d,i){
18692              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
18693              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
18694              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18695              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>';
18696              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;
18697              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;
18698              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+'"/>';
18699              var pct=Math.round((d.code||0)/tot2*100);
18700              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>';
18701            });
18702          } else {
18703            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
18704            LANG_D.forEach(function(d,i){
18705              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
18706              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
18707              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>';
18708              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;
18709              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;
18710              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+'"/>';
18711              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>';
18712            });
18713          }
18714          var ly=SH-legendH+4;
18715          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
18716          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
18717          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
18718          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
18719          s+='<g data-kind="code" style="cursor:pointer;">'
18720            +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
18721            +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
18722            +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
18723            +'</g>';
18724          s+='<g data-kind="comment" style="cursor:pointer;">'
18725            +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
18726            +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
18727            +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
18728            +'</g>';
18729          s+='<g data-kind="blank" style="cursor:pointer;">'
18730            +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
18731            +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
18732            +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
18733            +'</g>';
18734          s+='</svg>';
18735          el.innerHTML=s;
18736          wireMixLegendEl(el);
18737        }
18738        function wireMixLegendEl(container){
18739          var svg=container&&container.querySelector('svg');
18740          if(!svg)return;
18741          var legGs=svg.querySelectorAll('g[data-kind]');
18742          var allRects=svg.querySelectorAll('rect[data-kind]');
18743          if(!legGs.length)return;
18744          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';}}
18745          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='';}}
18746          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]);}
18747        }
18748        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
18749        renderComposition('abs');
18750        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
18751          btn.addEventListener('click',function(){
18752            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
18753            btn.classList.add('active');
18754            renderComposition(btn.getAttribute('data-rcomp'));
18755          });
18756        });
18757
18758        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
18759        function renderScatterInEl(el,hOvr){
18760          if(!el||!SCAT_D||!SCAT_D.length)return;
18761          var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
18762          var W=Math.max(320,el.offsetWidth||480);
18763          var cW=W-PL-PR,cH=H-PT-PB;
18764          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
18765          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
18766          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
18767          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">';
18768          [0,0.25,0.5,0.75,1].forEach(function(t){
18769            var y=PT+cH*(1-t);
18770            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
18771            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>';
18772          });
18773          [0,0.25,0.5,0.75,1].forEach(function(t){
18774            var x=PL+cW*t;
18775            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
18776            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>';
18777          });
18778          SCAT_D.forEach(function(d,i){
18779            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
18780            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
18781            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"/>';
18782            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>';
18783          });
18784          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>';
18785          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>';
18786          s+='</svg>';
18787          el.innerHTML=s;
18788        }
18789        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
18790
18791        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
18792        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
18793        // the old vertical column layout on wide containers.
18794        function renderSemanticInEl(el,key,sh){
18795          if(!el||!SEM_D||!SEM_D.length)return;
18796          var n2=SEM_D.length||1;
18797          var LW=112,SH=sh||Math.max(180,n2*28+26);
18798          var svgW=Math.max(320,el.offsetWidth||480);
18799          var BW=Math.max(120,svgW-LW-80);
18800          var topPad=4,botPad=14;
18801          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
18802          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
18803          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
18804          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">';
18805          SEM_D.forEach(function(d,i){
18806            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
18807            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>';
18808            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"/>';
18809            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>';
18810          });
18811          s+='</svg>';
18812          el.innerHTML=s;
18813        }
18814        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
18815        var semSel=document.getElementById('r-semantic-metric');
18816        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
18817        var semExpand=document.getElementById('r-semantic-expand');
18818        if(semExpand){
18819          semExpand.addEventListener('click',function(){
18820            var key=semSel?semSel.value:'functions';
18821            var n=SEM_D.length||1;
18822            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
18823            var modalH=Math.min(Math.max(360,n*38+60),maxH);
18824            var overlay=document.createElement('div');
18825            overlay.className='r-chart-modal-overlay';
18826            var optHtml=
18827              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
18828              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
18829              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
18830              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
18831              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
18832            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>';
18833            document.body.appendChild(overlay);
18834            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18835            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18836            var modalEl=document.getElementById('r-sem-modal-chart');
18837            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
18838            var modalSel=document.getElementById('r-sem-modal-metric');
18839            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
18840          });
18841        }
18842
18843        // ── Expand buttons: re-render charts at large size inside modal ──────────
18844        (function(){
18845          function makeExpandModal(title,mH,subtitle,ctrlHtml){
18846            var overlay=document.createElement('div');
18847            overlay.className='r-chart-modal-overlay';
18848            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
18849            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
18850            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>';
18851            document.body.appendChild(overlay);
18852            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
18853            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
18854            return overlay.querySelector('.r-expand-modal-chart');
18855          }
18856          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
18857          var compExpandBtn=document.getElementById('r-composition-expand');
18858          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
18859            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
18860            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
18861            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
18862              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
18863            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
18864            if(wrap){
18865              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
18866              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
18867                btn.addEventListener('click',function(){
18868                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
18869                  btn.classList.add('active');
18870                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
18871                });
18872              });
18873            }
18874          });}
18875          var scatExpandBtn=document.getElementById('r-scatter-expand');
18876          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
18877            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
18878            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
18879          });}
18880          var densExpandBtn=document.getElementById('r-density-expand');
18881          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
18882            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
18883            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
18884            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
18885          });}
18886          var avgExpandBtn=document.getElementById('r-avglines-expand');
18887          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
18888            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
18889            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
18890            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
18891          });}
18892          var subExpandBtn=document.getElementById('r-submodule-expand');
18893          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
18894            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
18895            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
18896            var metCtrl=
18897              '<select class="r-chart-select" id="r-sub-modal-metric">'
18898              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
18899              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
18900              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
18901              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
18902              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
18903              +'</select>';
18904            var sortCtrl=
18905              '<select class="r-chart-select" id="r-sub-modal-sort">'
18906              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
18907              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
18908              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
18909              +'</select>';
18910            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
18911            if(wrap){
18912              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
18913              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
18914              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
18915              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
18916              if(mSub)mSub.addEventListener('change',reRenderSub);
18917              if(mSort)mSort.addEventListener('change',reRenderSub);
18918            }
18919          });}
18920        })();
18921
18922        // ── Comment Density: comments / (code + comments) per language ───────────
18923        function renderDensityInEl(el,shOvr){
18924          if(!el||!LANG_D||!LANG_D.length)return;
18925          var n=LANG_D.length||1;
18926          var LW=112,SH=shOvr||Math.max(180,n*28+26);
18927          var svgW=Math.max(320,el.offsetWidth||480);
18928          var BW=Math.max(120,svgW-LW-80);
18929          var topPad=4,botPad=26;
18930          var rowTotal=Math.floor((SH-topPad-botPad)/n);
18931          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18932          var densities=LANG_D.map(function(d){
18933            var sig=(d.code||0)+(d.comments||0);
18934            return sig>0?(d.comments||0)/sig:0;
18935          });
18936          var maxDen=Math.max.apply(null,densities)||1;
18937          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">';
18938          LANG_D.forEach(function(d,i){
18939            var den=densities[i],bw=den/maxDen*BW;
18940            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
18941            var pct=Math.round(den*100);
18942            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>';
18943            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"/>';
18944            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
18945            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>';
18946          });
18947          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>';
18948          s+='</svg>';
18949          el.innerHTML=s;
18950        }
18951        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
18952        renderDensity();
18953
18954        // ── Avg Lines per File: code / files per language ─────────────────────
18955        function renderAvgLinesInEl(el,shOvr){
18956          if(!el||!LANG_D||!LANG_D.length)return;
18957          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
18958          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
18959          var n=data.length||1;
18960          var LW=112,SH=shOvr||Math.max(180,n*28+26);
18961          var svgW=Math.max(320,el.offsetWidth||480);
18962          var BW=Math.max(120,svgW-LW-80);
18963          var topPad=4,botPad=26;
18964          var rowTotal=Math.floor((SH-topPad-botPad)/n);
18965          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
18966          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
18967          var maxAvg=Math.max.apply(null,avgs)||1;
18968          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">';
18969          data.forEach(function(d,i){
18970            var avg=avgs[i],bw=avg/maxAvg*BW;
18971            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
18972            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>';
18973            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"/>';
18974            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
18975            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>';
18976          });
18977          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>';
18978          s+='</svg>';
18979          el.innerHTML=s;
18980        }
18981        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
18982        renderAvgLines();
18983
18984        // ── Repository Overview: overall row + per-submodule rows ────────────
18985        function renderSubmoduleInEl(el,key,sort,shOvr){
18986          if(!el)return;
18987          var overall={
18988            name:'Overall',
18989            code:{{ code_lines }},
18990            comment:{{ comment_lines }},
18991            blank:{{ blank_lines }},
18992            physical:{{ physical_lines }},
18993            files:{{ files_analyzed }},
18994            isOverall:true
18995          };
18996          var subs=SUB_D.slice();
18997          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
18998          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
18999          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
19000          var data=[overall].concat(subs);
19001          var rowH=32,bH=22,sepH=subs.length>0?14:0;
19002          var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
19003          var svgW=Math.max(320,el.offsetWidth||480);
19004          var LW=116,BW=Math.max(200,svgW-LW-54);
19005          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
19006          var OVERALL_COL='#6b7280';
19007          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">';
19008          var yOff=4;
19009          data.forEach(function(d,i){
19010            var v=d[key]||0,bw=v/maxV*BW,y=yOff;
19011            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
19012            var label=d.name||d.path||'?';
19013            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>';
19014            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
19015            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
19016            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>';
19017            yOff+=rowH;
19018            if(d.isOverall&&subs.length>0){
19019              yOff+=sepH;
19020            }
19021          });
19022          s+='</svg>';
19023          el.innerHTML=s;
19024        }
19025        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
19026        var subSel=document.getElementById('r-sub-metric');
19027        var sortSel=document.getElementById('r-sub-sort');
19028        renderSubmodule('code','desc');
19029        if(subSel){
19030          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
19031          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
19032        }
19033
19034        // Equalise heights within each chart row: if one chart in a grid row is taller
19035        // than its neighbour, re-render the shorter one at the taller height so bars fill
19036        // the available vertical space instead of leaving a gap.
19037        function syncRowHeights(){
19038          var avgEl=document.getElementById('r-avglines-chart');
19039          var subEl=document.getElementById('r-submodule-chart');
19040          if(avgEl&&subEl){
19041            var avgSvg=avgEl.querySelector('svg');
19042            var subSvg=subEl.querySelector('svg');
19043            if(avgSvg&&subSvg){
19044              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
19045              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
19046              var key=subSel?subSel.value||'code':'code';
19047              var sort=sortSel?sortSel.value:'desc';
19048              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
19049              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
19050            }
19051          }
19052          var semEl=document.getElementById('r-semantic-chart');
19053          var denEl=document.getElementById('r-density-chart');
19054          if(semEl&&denEl){
19055            var semSvg=semEl.querySelector('svg');
19056            var denSvg=denEl.querySelector('svg');
19057            if(semSvg&&denSvg){
19058              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
19059              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
19060              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
19061              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
19062            }
19063          }
19064        }
19065        syncRowHeights();
19066
19067        // Re-render all SVG charts when the window is resized so bars fill the card.
19068        var _rResizeTimer;
19069        window.addEventListener('resize',function(){
19070          clearTimeout(_rResizeTimer);
19071          _rResizeTimer=setTimeout(function(){
19072            var rcompBtn=document.querySelector('[data-rcomp].active');
19073            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
19074            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
19075            if(semSel)renderSemantic(semSel.value||'functions');
19076            renderDensity();
19077            renderAvgLines();
19078            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
19079            syncRowHeights();
19080          },120);
19081        });
19082      })();
19083
19084      (function randomizeWatermarks() {
19085        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
19086        if (!wms.length) return;
19087        var placed = [];
19088        function tooClose(top, left) {
19089          for (var i = 0; i < placed.length; i++) {
19090            var dt = Math.abs(placed[i][0] - top);
19091            var dl = Math.abs(placed[i][1] - left);
19092            if (dt < 20 && dl < 18) return true;
19093          }
19094          return false;
19095        }
19096        function pick(leftBand) {
19097          for (var attempt = 0; attempt < 50; attempt++) {
19098            var top = Math.random() * 85 + 5;
19099            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19100            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19101          }
19102          var top = Math.random() * 85 + 5;
19103          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
19104          placed.push([top, left]);
19105          return [top, left];
19106        }
19107        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
19108        var half = Math.floor(wms.length / 2);
19109        wms.forEach(function (img, i) {
19110          var pos = pick(i < half);
19111          var size = Math.floor(Math.random() * 100 + 160);
19112          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
19113          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
19114          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;
19115        });
19116      })();
19117
19118      (function spawnCodeParticles() {
19119        var container = document.getElementById('code-particles');
19120        if (!container) return;
19121        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'];
19122        for (var i = 0; i < 38; i++) {
19123          (function(idx) {
19124            var el = document.createElement('span');
19125            el.className = 'code-particle';
19126            el.textContent = snippets[idx % snippets.length];
19127            var left = Math.random() * 94 + 2;
19128            var top = Math.random() * 88 + 6;
19129            var dur = (Math.random() * 10 + 9).toFixed(1);
19130            var delay = (Math.random() * 18).toFixed(1);
19131            var rot = (Math.random() * 26 - 13).toFixed(1);
19132            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19133            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';
19134            container.appendChild(el);
19135          })(i);
19136        }
19137      })();
19138
19139      {% if pdf_generating %}
19140      // Poll for PDF readiness and swap the disabled button to a live link once done.
19141      (function() {
19142        var openBtn = document.getElementById('pdf-open-btn');
19143        var dlBtn = document.getElementById('pdf-download-btn');
19144        function checkPdf() {
19145          fetch('/api/runs/{{ run_id }}/pdf-status')
19146            .then(function(r) { return r.json(); })
19147            .then(function(d) {
19148              if (d.ready) {
19149                if (openBtn) {
19150                  var a = document.createElement('a');
19151                  a.className = 'button';
19152                  a.id = 'pdf-open-btn';
19153                  a.href = '/runs/pdf/{{ run_id }}';
19154                  a.target = '_blank';
19155                  a.rel = 'noopener';
19156                  a.textContent = 'Open PDF';
19157                  openBtn.replaceWith(a);
19158                }
19159                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
19160              } else {
19161                setTimeout(checkPdf, 3000);
19162              }
19163            })
19164            .catch(function() { setTimeout(checkPdf, 5000); });
19165        }
19166        setTimeout(checkPdf, 3000);
19167      })();
19168      {% endif %}
19169
19170    })();
19171  </script>
19172  <script nonce="{{ csp_nonce }}">
19173  (function(){
19174    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'}];
19175    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);});}
19176    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19177    function init(){
19178      var btn=document.getElementById('settings-btn');if(!btn)return;
19179      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19180      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>';
19181      document.body.appendChild(m);
19182      var g=document.getElementById('scheme-grid');
19183      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);});
19184      var cl=document.getElementById('settings-close');
19185      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);
19186      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');});
19187      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19188      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19189    }
19190    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19191  }());
19192  </script>
19193  <footer class="site-footer">
19194    local code analysis - metrics, history and reports
19195    &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>
19196    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19197    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19198    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19199    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19200  </footer>
19201  {% if confluence_configured %}
19202  <script nonce="{{ csp_nonce }}">
19203  (function() {
19204    var postBtn = document.getElementById('postConfluenceBtn');
19205    var copyBtn = document.getElementById('copyWikiBtn');
19206    var modal   = document.getElementById('confluenceModal');
19207    if (!postBtn || !modal) return;
19208
19209    postBtn.addEventListener('click', function() {
19210      document.getElementById('confStatus').style.display = 'none';
19211      modal.style.display = 'flex';
19212    });
19213    document.getElementById('confCancelBtn').addEventListener('click', function() {
19214      modal.style.display = 'none';
19215    });
19216    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19217
19218    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
19219      var btn = this;
19220      btn.disabled = true;
19221      var status = document.getElementById('confStatus');
19222      status.style.display = 'block';
19223      status.style.background = '#dbeafe';
19224      status.style.color = '#1e40af';
19225      status.textContent = 'Posting to Confluence…';
19226      var resp = await fetch('/api/confluence/post', {
19227        method: 'POST',
19228        headers: { 'Content-Type': 'application/json' },
19229        body: JSON.stringify({
19230          run_id: '{{ run_id }}',
19231          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
19232          report_url: document.getElementById('confReportUrl').value.trim() || null
19233        })
19234      });
19235      var data = await resp.json();
19236      if (data.ok) {
19237        status.style.background = '#dcfce7'; status.style.color = '#166534';
19238        status.textContent = 'Posted! Page ID: ' + data.page_id;
19239      } else {
19240        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19241        status.textContent = 'Error: ' + (data.error || 'Unknown error');
19242      }
19243      btn.disabled = false;
19244    });
19245
19246    if (copyBtn) {
19247      copyBtn.addEventListener('click', async function() {
19248        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
19249        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
19250        var text = await resp.text();
19251        try {
19252          await navigator.clipboard.writeText(text);
19253          var orig = copyBtn.textContent;
19254          copyBtn.textContent = 'Copied!';
19255          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
19256        } catch(e) {
19257          alert('Clipboard write failed — check browser permissions.');
19258        }
19259      });
19260    }
19261  })();
19262  </script>
19263  {% endif %}
19264  <script nonce="{{ csp_nonce }}">
19265  (function() {
19266    var deleteBtn = document.getElementById('delete-run-btn');
19267    var modal     = document.getElementById('delete-run-modal');
19268    var cancelBtn = document.getElementById('delete-run-cancel');
19269    var confirmBtn= document.getElementById('delete-run-confirm');
19270    if (!deleteBtn || !modal) return;
19271    deleteBtn.addEventListener('click', function() {
19272      document.getElementById('delete-run-status').style.display = 'none';
19273      modal.style.display = 'flex';
19274    });
19275    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
19276    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
19277    confirmBtn.addEventListener('click', async function() {
19278      confirmBtn.disabled = true;
19279      cancelBtn.disabled = true;
19280      var status = document.getElementById('delete-run-status');
19281      status.style.display = 'block';
19282      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
19283      status.textContent = 'Deleting…';
19284      try {
19285        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
19286        if (resp.status === 204 || resp.ok) {
19287          status.style.background = '#dcfce7'; status.style.color = '#166534';
19288          status.textContent = 'Deleted. Redirecting…';
19289          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
19290        } else {
19291          var d = await resp.json().catch(function(){return {};});
19292          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19293          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
19294          confirmBtn.disabled = false;
19295          cancelBtn.disabled = false;
19296        }
19297      } catch (e) {
19298        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
19299        status.textContent = 'Network error: ' + String(e);
19300        confirmBtn.disabled = false;
19301        cancelBtn.disabled = false;
19302      }
19303    });
19304  })();
19305  </script>
19306  <script nonce="{{ csp_nonce }}">(function(){
19307    var bundleBtn = document.getElementById('download-bundle-btn');
19308    if (bundleBtn) {
19309      bundleBtn.addEventListener('click', function() {
19310        bundleBtn.disabled = true;
19311        var orig = bundleBtn.textContent;
19312        bundleBtn.textContent = 'Preparing…';
19313        fetch('/api/runs/{{ run_id }}/bundle')
19314          .then(function(r) {
19315            if (!r.ok) throw new Error('HTTP ' + r.status);
19316            return r.blob();
19317          })
19318          .then(function(blob) {
19319            var url = URL.createObjectURL(blob);
19320            var a = document.createElement('a');
19321            a.href = url;
19322            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
19323            document.body.appendChild(a);
19324            a.click();
19325            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
19326            bundleBtn.disabled = false;
19327            bundleBtn.textContent = orig;
19328          })
19329          .catch(function(e) {
19330            bundleBtn.disabled = false;
19331            bundleBtn.textContent = orig;
19332            alert('Bundle download failed: ' + String(e));
19333          });
19334      });
19335    }
19336  })();</script>
19337  <script nonce="{{ csp_nonce }}">(function(){
19338    var dot=document.getElementById('status-dot');
19339    var pingEl=document.getElementById('server-ping-ms');
19340    var tipEl=document.getElementById('server-tip-ping');
19341    var fm=document.getElementById('footer-mode');
19342    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)';}}
19343    function doPing(){
19344      var t0=performance.now();
19345      fetch('/healthz',{cache:'no-store'})
19346        .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);})
19347        .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)';}});
19348    }
19349    doPing();
19350    setInterval(doPing,5000);
19351    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');}
19352  })();</script>
19353  {% if let Some(banner) = report_header_footer %}
19354  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
19355  {% endif %}
19356</body>
19357</html>
19358"##,
19359    ext = "html"
19360)]
19361// Template structs need many bool fields to pass Askama rendering flags.
19362#[allow(clippy::struct_excessive_bools)]
19363struct ResultTemplate {
19364    version: &'static str,
19365    report_title: String,
19366    project_path: String,
19367    output_dir: String,
19368    run_id: String,
19369    files_analyzed: u64,
19370    files_skipped: u64,
19371    physical_lines: u64,
19372    code_lines: u64,
19373    comment_lines: u64,
19374    blank_lines: u64,
19375    mixed_lines: u64,
19376    functions: u64,
19377    classes: u64,
19378    variables: u64,
19379    imports: u64,
19380    html_url: Option<String>,
19381    pdf_url: Option<String>,
19382    json_url: Option<String>,
19383    html_download_url: Option<String>,
19384    pdf_download_url: Option<String>,
19385    json_download_url: Option<String>,
19386    html_path: Option<String>,
19387    json_path: Option<String>,
19388    prev_run_id: Option<String>,
19389    prev_run_timestamp: Option<String>,
19390    prev_run_code_lines: Option<u64>,
19391    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
19392    prev_fa_str: String,
19393    prev_fs_str: String,
19394    prev_pl_str: String,
19395    prev_cl_str: String,
19396    prev_cml_str: String,
19397    prev_bl_str: String,
19398    // Signed change column for main metrics
19399    delta_fa_str: String,
19400    delta_fa_class: String,
19401    delta_fs_str: String,
19402    delta_fs_class: String,
19403    delta_pl_str: String,
19404    delta_pl_class: String,
19405    delta_cl_str: String,
19406    delta_cl_class: String,
19407    delta_cml_str: String,
19408    delta_cml_class: String,
19409    delta_bl_str: String,
19410    delta_bl_class: String,
19411    // delta vs previous scan
19412    delta_lines_added: Option<i64>,
19413    delta_lines_removed: Option<i64>,
19414    delta_lines_net_str: String,
19415    delta_lines_net_class: String,
19416    delta_files_added: Option<usize>,
19417    delta_files_removed: Option<usize>,
19418    delta_files_modified: Option<usize>,
19419    delta_files_unchanged: Option<usize>,
19420    delta_unmodified_lines: Option<u64>,
19421    // git context
19422    git_branch: Option<String>,
19423    git_commit: Option<String>,
19424    git_commit_long: Option<String>,
19425    git_author: Option<String>,
19426    git_commit_url: Option<String>,
19427    // scan metadata for hero section
19428    scan_performed_by: String,
19429    scan_time_display: String,
19430    os_display: String,
19431    test_count: u64,
19432    // history
19433    prev_scan_count: usize,
19434    current_scan_number: usize,
19435    // submodule breakdown (empty when not requested)
19436    submodule_rows: Vec<SubmoduleRow>,
19437    scan_config_url: String,
19438    lang_chart_json: String,
19439    // Askama reads these via proc-macro expansion; clippy can't trace through it.
19440    #[allow(dead_code)]
19441    scatter_chart_json: String,
19442    #[allow(dead_code)]
19443    semantic_chart_json: String,
19444    #[allow(dead_code)]
19445    submodule_chart_json: String,
19446    #[allow(dead_code)]
19447    has_submodule_data: bool,
19448    #[allow(dead_code)]
19449    has_semantic_data: bool,
19450    pdf_generating: bool,
19451    csp_nonce: String,
19452    /// Whether Confluence integration is configured — shows Post button when true.
19453    confluence_configured: bool,
19454    server_mode: bool,
19455    /// Header/footer identification banner, mirrored from the HTML/PDF report.
19456    report_header_footer: Option<String>,
19457    run_id_short: String,
19458    /// True when rendering a static offline file (index.html); hides server-only actions.
19459    #[allow(dead_code)]
19460    is_offline: bool,
19461}
19462
19463#[derive(Template)]
19464#[template(
19465    source = r##"
19466<!doctype html>
19467<html lang="en">
19468<head>
19469  <meta charset="utf-8">
19470  <meta name="viewport" content="width=device-width, initial-scale=1">
19471  <title>OxideSLOC | Analyzing…</title>
19472  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19473  <style nonce="{{ csp_nonce }}">
19474    :root {
19475      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19476      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19477      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19478      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19479    }
19480    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19481    *{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;}
19482    .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);}
19483    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19484    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
19485    .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));}
19486    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19487    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19488    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19489    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19490    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19491    @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; } }
19492    .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;}
19493    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19494    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19495    .page-body{padding:32px 24px 36px;}
19496    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
19497    .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;}
19498    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
19499    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
19500    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
19501    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
19502    .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;}
19503    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
19504    .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;}
19505    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
19506    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
19507    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
19508    .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;}
19509    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
19510    .hidden{display:none!important;}
19511    .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;}
19512    .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;}
19513    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
19514    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
19515    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
19516    .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);}
19517    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
19518    .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;}
19519    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
19520    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19521    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19522    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
19523    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19524    .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;}
19525    @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));}}
19526    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19527    .site-footer a{color:var(--muted);}
19528    .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;}
19529    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
19530    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
19531    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
19532  </style>
19533</head>
19534<body>
19535  <div class="background-watermarks" aria-hidden="true">
19536    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19537    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19538    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19539    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19540    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19541    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19542  </div>
19543  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19544  <nav class="top-nav">
19545    <div class="top-nav-inner">
19546      <a href="/" class="brand">
19547        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
19548        <div class="brand-copy">
19549          <h1 class="brand-title">OxideSLOC</h1>
19550          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19551        </div>
19552      </a>
19553      <div class="nav-right">
19554        <a class="nav-pill" href="/">Home</a>
19555        <div class="nav-dropdown">
19556          <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>
19557          <div class="nav-dropdown-menu">
19558            <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>
19559          </div>
19560        </div>
19561        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19562        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19563        <div class="nav-dropdown">
19564          <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>
19565          <div class="nav-dropdown-menu">
19566            <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>
19567          </div>
19568        </div>
19569        <div class="server-status-wrap" id="server-status-wrap">
19570          <div class="nav-pill server-online-pill" id="server-status-pill">
19571            <span class="status-dot" id="status-dot"></span>
19572            <span id="server-status-label">Server</span>
19573            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19574          </div>
19575          <div class="server-status-tip">
19576            OxideSLOC is running — accessible on your network.
19577            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19578          </div>
19579        </div>
19580        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19581          <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>
19582        </button>
19583        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19584          <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>
19585          <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>
19586        </button>
19587      </div>
19588    </div>
19589  </nav>
19590  <div class="page-body">
19591    <div class="wait-panel">
19592      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
19593      <h2 class="wait-title">Analyzing your project…</h2>
19594      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19595      <div class="path-block">{{ project_path }}</div>
19596      <div class="metrics-row">
19597        <div class="metric-card">
19598          <div class="metric-label">Elapsed</div>
19599          <div class="metric-value" id="elapsed">0s</div>
19600        </div>
19601        <div class="metric-card">
19602          <div class="metric-label">Phase</div>
19603          <div class="metric-value" id="phase">Starting</div>
19604        </div>
19605        <div class="metric-card hidden" id="files-card">
19606          <div class="metric-label">Files</div>
19607          <div class="metric-value" id="files-progress">0</div>
19608        </div>
19609      </div>
19610      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
19611      <div class="warn-slow hidden" id="warn-slow">
19612        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.
19613      </div>
19614      <div class="err-panel hidden" id="err-panel">
19615        <strong>Analysis failed</strong>
19616        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
19617      </div>
19618      <div class="actions hidden" id="actions">
19619        <a href="/scan" class="btn-primary">Try Again</a>
19620        <a href="/view-reports" class="btn-outline">View Reports</a>
19621      </div>
19622    </div>
19623  </div>
19624  <script nonce="{{ csp_nonce }}">
19625    (function() {
19626      var WAIT_ID = {{ wait_id_json|safe }};
19627      var startTime = Date.now();
19628      var pollInterval = 1500;
19629      var retries = 0;
19630      var maxRetries = 5;
19631      var warnShown = false;
19632
19633      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();}
19634
19635      function elapsed() {
19636        return Math.floor((Date.now() - startTime) / 1000);
19637      }
19638
19639      function updateElapsed() {
19640        var s = elapsed();
19641        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
19642      }
19643
19644      function setPhase(txt) {
19645        document.getElementById('phase').textContent = txt;
19646      }
19647
19648      var elapsedTimer = setInterval(updateElapsed, 1000);
19649
19650      function poll() {
19651        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
19652          .then(function(r) {
19653            if (!r.ok) throw new Error('HTTP ' + r.status);
19654            return r.json();
19655          })
19656          .then(function(data) {
19657            retries = 0;
19658            if (data.state === 'complete') {
19659              clearInterval(elapsedTimer);
19660              setPhase('Done');
19661              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
19662            } else if (data.state === 'failed') {
19663              clearInterval(elapsedTimer);
19664              setPhase('Failed');
19665              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
19666              document.getElementById('err-panel').classList.remove('hidden');
19667              document.getElementById('actions').classList.remove('hidden');
19668            } else {
19669              // still running
19670              var s = elapsed();
19671              if (s > 90 && !warnShown) {
19672                warnShown = true;
19673                document.getElementById('warn-slow').classList.remove('hidden');
19674              }
19675              setPhase(data.phase || 'Running');
19676              var fd = data.files_done || 0, ft = data.files_total || 0;
19677              if (ft > 0) {
19678                var card = document.getElementById('files-card');
19679                if (card) card.classList.remove('hidden');
19680                var fp = document.getElementById('files-progress');
19681                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
19682              }
19683              setTimeout(poll, pollInterval);
19684            }
19685          })
19686          .catch(function(err) {
19687            retries++;
19688            if (retries >= maxRetries) {
19689              clearInterval(elapsedTimer);
19690              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
19691              document.getElementById('err-panel').classList.remove('hidden');
19692              document.getElementById('actions').classList.remove('hidden');
19693            } else {
19694              // exponential back-off capped at 8s
19695              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
19696            }
19697          });
19698      }
19699
19700      setTimeout(poll, pollInterval);
19701    })();
19702  </script>
19703  <footer class="site-footer">
19704    local code analysis - metrics, history and reports
19705    &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>
19706    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19707    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19708    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19709    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19710  </footer>
19711  <script nonce="{{ csp_nonce }}">
19712    (function(){
19713      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
19714      if(s==="dark")b.classList.add("dark-theme");
19715      var tt=document.getElementById("theme-toggle");
19716      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
19717    })();
19718    (function spawnCodeParticles(){
19719      var c=document.getElementById('code-particles');if(!c)return;
19720      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'];
19721      for(var i=0;i<32;i++){(function(idx){
19722        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
19723        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
19724        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
19725        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
19726        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
19727        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
19728        c.appendChild(el);
19729      })(i);}
19730    })();
19731    (function randomizeWatermarks(){
19732      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19733      var placed=[];
19734      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;}
19735      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];}
19736      var half=Math.floor(wms.length/2);
19737      wms.forEach(function(img,i){
19738        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
19739        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
19740        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
19741        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
19742        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
19743        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
19744      });
19745    })();
19746  </script>
19747  <script nonce="{{ csp_nonce }}">
19748  (function(){
19749    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'}];
19750    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);});}
19751    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19752    function init(){
19753      var btn=document.getElementById('settings-btn');if(!btn)return;
19754      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19755      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>';
19756      document.body.appendChild(m);
19757      var g=document.getElementById('scheme-grid');
19758      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);});
19759      var cl=document.getElementById('settings-close');
19760      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);
19761      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');});
19762      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19763      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19764    }
19765    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19766  }());
19767  </script>
19768  <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>
19769</body>
19770</html>
19771"##,
19772    ext = "html"
19773)]
19774struct ScanWaitTemplate {
19775    version: &'static str,
19776    wait_id_json: String,
19777    project_path: String,
19778    csp_nonce: String,
19779}
19780
19781#[derive(Template)]
19782#[template(
19783    source = r##"
19784<!doctype html>
19785<html lang="en">
19786<head>
19787  <meta charset="utf-8">
19788  <meta name="viewport" content="width=device-width, initial-scale=1">
19789  <title>OxideSLOC | Error</title>
19790  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19791  <style nonce="{{ csp_nonce }}">
19792    :root {
19793      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19794      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19795      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
19796      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19797    }
19798    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
19799    *{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;}
19800    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19801    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19802    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
19803    .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);}
19804    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19805    .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));}
19806    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19807    .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;}
19808    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19809    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19810    @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; } }
19811    .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;}
19812    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19813    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
19814    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19815    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19816    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19817    .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;}
19818    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19819    .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);}
19820    .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;}
19821    .settings-close:hover{color:var(--text);background:var(--surface-2);}
19822    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19823    .settings-modal-body{padding:14px 16px 16px;}
19824    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19825    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19826    .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;}
19827    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19828    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19829    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19830    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19831    .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;}
19832    .tz-select:focus{border-color:var(--oxide);}
19833    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
19834    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
19835    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
19836    .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;}
19837    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
19838    .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);}
19839    .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;}
19840    .btn-secondary:hover{background:var(--line);}
19841    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
19842    .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;}
19843    .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;}
19844    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
19845    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
19846    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
19847    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
19848    .bug-report-panel.open{display:flex;}
19849    .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;}
19850    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
19851    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
19852    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
19853    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
19854    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
19855    .br-network-badge.online .br-net-dot{background:#2a6846;}
19856    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
19857    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
19858    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
19859    .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;}
19860    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
19861    .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;}
19862    .btn-sm:hover{background:var(--line);}
19863    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
19864    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
19865    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
19866    .bug-report-hint a:hover{text-decoration:underline;}
19867    .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;}
19868    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
19869    .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;}
19870    .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;}
19871    .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;}
19872    @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));}}
19873    .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;}
19874  </style>
19875</head>
19876<body>
19877  <div class="background-watermarks" aria-hidden="true">
19878    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19879    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19880    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19881    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19882    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19883    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19884  </div>
19885  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19886  <div class="top-nav">
19887    <div class="top-nav-inner">
19888      <a class="brand" href="/">
19889        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
19890        <div class="brand-copy">
19891          <div class="brand-title">OxideSLOC</div>
19892          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
19893        </div>
19894      </a>
19895      <div class="nav-right">
19896        <a class="nav-pill" href="/">Home</a>
19897        <div class="nav-dropdown">
19898          <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>
19899          <div class="nav-dropdown-menu">
19900            <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>
19901          </div>
19902        </div>
19903        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19904        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19905        <div class="nav-dropdown">
19906          <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>
19907          <div class="nav-dropdown-menu">
19908            <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>
19909          </div>
19910        </div>
19911        <div class="server-status-wrap" id="server-status-wrap">
19912          <div class="nav-pill server-online-pill" id="server-status-pill">
19913            <span class="status-dot" id="status-dot"></span>
19914            <span id="server-status-label">Server</span>
19915            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19916          </div>
19917          <div class="server-status-tip">
19918            OxideSLOC is running — accessible on your network.
19919            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19920          </div>
19921        </div>
19922        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19923          <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>
19924        </button>
19925        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19926          <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>
19927          <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>
19928        </button>
19929      </div>
19930    </div>
19931  </div>
19932
19933  <div class="page">
19934    <div class="panel">
19935      <h1>Error</h1>
19936      <div class="error-box" id="error-msg-text">{{ message }}</div>
19937      <div id="br-meta" hidden
19938        data-version="{{ version }}"
19939        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
19940        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
19941      <div class="actions">
19942        <a class="btn-primary" href="/scan">Back to setup</a>
19943        {% if let Some(report_url) = last_report_url %}
19944        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
19945        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
19946        {% else %}
19947        <a class="btn-secondary" href="/view-reports">View Reports</a>
19948        {% endif %}
19949      </div>
19950      <div class="bug-report-section" id="bug-report-section">
19951        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
19952          <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>
19953          Generate Bug Report
19954          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19955        </button>
19956        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
19957          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
19958          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
19959          <div class="bug-report-btns">
19960            <button type="button" class="btn-sm" id="bug-report-copy">
19961              <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>
19962              Copy to clipboard
19963            </button>
19964            <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;">
19965              <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>
19966              Open GitHub Issue
19967            </a>
19968            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
19969              <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>
19970              Save as file
19971            </button>
19972          </div>
19973          <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>
19974          <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>
19975        </div>
19976      </div>
19977    </div>
19978  </div>
19979  <footer class="site-footer">
19980    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
19981    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19982    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19983    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19984    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19985  </footer>
19986  <script nonce="{{ csp_nonce }}">(function(){
19987    var meta=document.getElementById('br-meta');
19988    var pre=document.getElementById('bug-report-pre');
19989    var copyBtn=document.getElementById('bug-report-copy');
19990    var trigger=document.getElementById('bug-report-trigger');
19991    var panel=document.getElementById('bug-report-panel');
19992    var networkBadge=document.getElementById('br-network-badge');
19993    var networkLabel=document.getElementById('br-network-label');
19994    var ghLink=document.getElementById('bug-report-github-link');
19995    var saveBtn=document.getElementById('bug-report-save');
19996    var hintOnline=document.getElementById('br-hint-online');
19997    var hintOffline=document.getElementById('br-hint-offline');
19998    if(!meta||!pre)return;
19999    var ver=meta.getAttribute('data-version')||'';
20000    var runId=meta.getAttribute('data-run-id')||'';
20001    var code=meta.getAttribute('data-error-code')||'';
20002    var msgEl=document.getElementById('error-msg-text');
20003    var msg=msgEl?msgEl.textContent.trim():'';
20004    function getBrowser(){
20005      var ua=navigator.userAgent;
20006      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
20007      if(!m)return 'Unknown browser';
20008      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
20009      return n+' '+m[2];
20010    }
20011    var lines=['oxide-sloc Bug Report','==============================',''];
20012    lines.push('App version:  v'+ver);
20013    if(code)lines.push('HTTP status:  '+code);
20014    if(runId)lines.push('Run ID:       '+runId);
20015    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
20016    lines.push('Timestamp:    '+new Date().toISOString());
20017    lines.push('Browser:      '+getBrowser());
20018    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
20019    lines.push('');
20020    lines.push('Error message:');
20021    lines.push(msg);
20022    lines.push('');
20023    lines.push('Steps to reproduce:');
20024    lines.push('  1. ');
20025    lines.push('');
20026    lines.push('Expected behavior:');
20027    lines.push('  ');
20028    pre.textContent=lines.join('\n');
20029    function applyNetwork(online){
20030      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
20031      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
20032      if(ghLink){
20033        if(online){
20034          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
20035          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
20036        }
20037        ghLink.style.display=online?'inline-flex':'none';
20038      }
20039      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
20040      if(hintOnline)hintOnline.style.display=online?'block':'none';
20041      if(hintOffline)hintOffline.style.display=online?'none':'block';
20042    }
20043    applyNetwork(navigator.onLine);
20044    var probed=false;
20045    function probeNetwork(){
20046      if(probed)return;probed=true;
20047      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
20048      var probeIdx=0;
20049      function tryNext(){
20050        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
20051        var u=probeUrls[probeIdx++];
20052        var c2=new AbortController();
20053        var t2=setTimeout(function(){c2.abort();},4000);
20054        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
20055          .then(function(){clearTimeout(t2);applyNetwork(true);})
20056          .catch(function(){clearTimeout(t2);tryNext();});
20057      }
20058      tryNext();
20059    }
20060    if(trigger&&panel){
20061      trigger.addEventListener('click',function(){
20062        var open=panel.classList.toggle('open');
20063        trigger.classList.toggle('open',open);
20064        trigger.setAttribute('aria-expanded',open?'true':'false');
20065        if(open)probeNetwork();
20066      });
20067    }
20068    if(copyBtn){
20069      copyBtn.addEventListener('click',function(){
20070        var txt=pre.textContent;
20071        if(navigator.clipboard&&navigator.clipboard.writeText){
20072          navigator.clipboard.writeText(txt).then(function(){
20073            copyBtn.textContent='✓ Copied!';
20074            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);
20075          });
20076        }else{
20077          var ta=document.createElement('textarea');
20078          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
20079          document.body.appendChild(ta);ta.select();
20080          try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
20081          document.body.removeChild(ta);
20082        }
20083      });
20084    }
20085    if(saveBtn){
20086      saveBtn.addEventListener('click',function(){
20087        var txt=pre.textContent;
20088        var blob=new Blob([txt],{type:'text/plain'});
20089        var url=URL.createObjectURL(blob);
20090        var a=document.createElement('a');
20091        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
20092        document.body.appendChild(a);a.click();
20093        document.body.removeChild(a);URL.revokeObjectURL(url);
20094      });
20095    }
20096  })();</script>
20097  <script nonce="{{ csp_nonce }}">
20098    (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");});})();
20099    (function spawnCodeParticles() {
20100      var container = document.getElementById('code-particles');
20101      if (!container) return;
20102      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'];
20103      for (var i = 0; i < 38; i++) {
20104        (function(idx) {
20105          var el = document.createElement('span');
20106          el.className = 'code-particle';
20107          el.textContent = snippets[idx % snippets.length];
20108          var left = Math.random() * 94 + 2;
20109          var top = Math.random() * 88 + 6;
20110          var dur = (Math.random() * 10 + 9).toFixed(1);
20111          var delay = (Math.random() * 18).toFixed(1);
20112          var rot = (Math.random() * 26 - 13).toFixed(1);
20113          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20114          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';
20115          container.appendChild(el);
20116        })(i);
20117      }
20118    })();
20119    (function randomizeWatermarks() {
20120      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20121      var placed = [];
20122      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; }
20123      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]; }
20124      var half = Math.floor(wms.length/2);
20125      wms.forEach(function(img, i) {
20126        var pos = pick(i < half);
20127        var w = Math.floor(Math.random()*60+80);
20128        var rot = (Math.random()*40-20).toFixed(1);
20129        var op = (Math.random()*0.08+0.05).toFixed(2);
20130        var animDur = (Math.random()*6+5).toFixed(1);
20131        var animDelay = (Math.random()*10).toFixed(1);
20132        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';
20133      });
20134    })();
20135  </script>
20136  <script nonce="{{ csp_nonce }}">
20137  (function(){
20138    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'}];
20139    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);});}
20140    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20141    function init(){
20142      var btn=document.getElementById('settings-btn');if(!btn)return;
20143      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20144      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>';
20145      document.body.appendChild(m);
20146      var g=document.getElementById('scheme-grid');
20147      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);});
20148      var cl=document.getElementById('settings-close');
20149      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);
20150      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');});
20151      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20152      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20153    }
20154    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20155  }());
20156  </script>
20157  <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>
20158</body>
20159</html>
20160"##,
20161    ext = "html"
20162)]
20163struct ErrorTemplate {
20164    message: String,
20165    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
20166    last_report_url: Option<String>,
20167    /// Label for the secondary action button; defaults to "View last report" when None.
20168    last_report_label: Option<String>,
20169    /// Run ID to surface in the bug report; `None` when not applicable.
20170    run_id: Option<String>,
20171    /// HTTP status code to surface in the bug report; `None` when unknown.
20172    error_code: Option<u16>,
20173    csp_nonce: String,
20174    version: &'static str,
20175}
20176
20177// ── LocateFileTemplate ────────────────────────────────────────────────────────
20178
20179#[derive(Template)]
20180#[template(
20181    source = r##"
20182<!doctype html>
20183<html lang="en">
20184<head>
20185  <meta charset="utf-8">
20186  <meta name="viewport" content="width=device-width, initial-scale=1">
20187  <title>OxideSLOC | Locate Report</title>
20188  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20189  <style nonce="{{ csp_nonce }}">
20190    :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);}
20191    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
20192    *{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;}
20193    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20194    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20195    .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);}
20196    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20197    .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));}
20198    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20199    .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;}
20200    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20201    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20202    @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;}}
20203    .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;}
20204    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20205    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20206    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20207    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20208    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
20209    .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;}
20210    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20211    .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);}
20212    .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;}
20213    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20214    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20215    .settings-modal-body{padding:14px 16px 16px;}
20216    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20217    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20218    .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;}
20219    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20220    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20221    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20222    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20223    .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;}
20224    .tz-select:focus{border-color:var(--oxide);}
20225    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20226    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20227    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20228    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
20229    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
20230    .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;}
20231    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
20232    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20233    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20234    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20235    .locate-row{display:flex;gap:8px;align-items:stretch;}
20236    .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;}
20237    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20238    body.dark-theme .locate-input{background:var(--surface-2);}
20239    .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;}
20240    .warning-banner.show{display:flex;}
20241    .warning-banner svg{flex:0 0 auto;}
20242    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
20243    .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;}
20244    .error-inline.show{display:flex;}
20245    .error-inline svg{flex:0 0 auto;margin-top:2px;}
20246    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
20247    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
20248    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
20249    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
20250    .err-kv-p{margin:0 0 4px;}
20251    .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;}
20252    .success-inline.show{display:flex;}
20253    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20254    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
20255    .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;}
20256    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
20257    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
20258    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
20259    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
20260    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
20261    .fh-row:last-child{border-bottom:none;}
20262    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
20263    .fh-dir{font-weight:800;color:var(--text);}
20264    .fh-hl{color:var(--oxide);font-weight:700;}
20265    .fh-muted{color:var(--muted);}
20266    .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;}
20267    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
20268    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
20269    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
20270    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
20271    .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;}
20272    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
20273    .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;}
20274    .btn-secondary:hover{background:var(--line);}
20275    .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;}
20276    .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;}
20277    .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;}
20278    @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));}}
20279    .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;}
20280    .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;}
20281    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
20282  </style>
20283</head>
20284<body>
20285  <div class="background-watermarks" aria-hidden="true">
20286    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20287    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20288    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20289    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20290    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20291    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20292  </div>
20293  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20294  <div class="top-nav">
20295    <div class="top-nav-inner">
20296      <a class="brand" href="/">
20297        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20298        <div class="brand-copy">
20299          <div class="brand-title">OxideSLOC</div>
20300          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20301        </div>
20302      </a>
20303      <div class="nav-right">
20304        <a class="nav-pill" href="/">Home</a>
20305        <div class="nav-dropdown">
20306          <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>
20307          <div class="nav-dropdown-menu">
20308            <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>
20309          </div>
20310        </div>
20311        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
20312        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20313        <div class="nav-dropdown">
20314          <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>
20315          <div class="nav-dropdown-menu">
20316            <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>
20317          </div>
20318        </div>
20319        <div class="server-status-wrap" id="server-status-wrap">
20320          <div class="nav-pill server-online-pill" id="server-status-pill">
20321            <span class="status-dot" id="status-dot"></span>
20322            <span id="server-status-label">Server</span>
20323            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20324          </div>
20325          <div class="server-status-tip">
20326            OxideSLOC is running &mdash; accessible on your network.
20327            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20328          </div>
20329        </div>
20330        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20331          <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>
20332        </button>
20333        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20334          <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>
20335          <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>
20336        </button>
20337      </div>
20338    </div>
20339  </div>
20340
20341  <div class="page">
20342    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
20343    <div class="panel">
20344      <h1>Report File Not Found</h1>
20345      <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>
20346      <div class="field-label">Missing file</div>
20347      <div class="filename-chip">
20348        <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>
20349        {{ expected_filename }}
20350      </div>
20351      <div class="locate-section">
20352        <h2>Locate Scan Output Folder</h2>
20353        <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>
20354        <p>OxideSLOC will find the correct files inside automatically.</p>
20355        <div class="locate-row">
20356          <input type="text" id="locate-file-input"
20357                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
20358                 class="locate-input" autocomplete="off" spellcheck="false">
20359          {% if !server_mode %}
20360          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
20361          {% endif %}
20362        </div>
20363        <div class="warning-banner" id="filename-warning">
20364          <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>
20365          <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>
20366        </div>
20367        <div class="error-inline" id="locate-error">
20368          <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>
20369          <span id="locate-error-text"></span>
20370        </div>
20371        <div class="success-inline" id="locate-success">
20372          <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>
20373          <span>Scan restored &mdash; loading report&hellip;</span>
20374        </div>
20375        <div class="btn-row">
20376          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
20377          <a class="btn-secondary" href="/view-reports">View Reports</a>
20378        </div>
20379        <div class="folder-hint-shell">
20380          <div class="folder-hint-hdr">
20381            <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>
20382            Expected Folder Structure &mdash; Select the Top-Level Folder
20383          </div>
20384          <div class="folder-hint-body">
20385            <div class="fh-row">
20386              <span class="fh-tog">&#9658;</span>
20387              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
20388              <span class="fh-badge">&larr; select this</span>
20389            </div>
20390            <div class="fh-row fh-i1">
20391              <span class="fh-tog">&#9658;</span>
20392              <span class="fh-dir">html/</span>
20393            </div>
20394            <div class="fh-row fh-i2">
20395              <span class="fh-bul">&#8226;</span>
20396              <span class="fh-hl">{{ expected_filename }}</span>
20397            </div>
20398            <div class="fh-row fh-i1">
20399              <span class="fh-tog">&#9658;</span>
20400              <span class="fh-dir">json/</span>
20401            </div>
20402            <div class="fh-row fh-i2">
20403              <span class="fh-bul">&#8226;</span>
20404              <span class="fh-muted">result_*.json</span>
20405            </div>
20406            <div class="fh-row fh-i1">
20407              <span class="fh-tog">&#9658;</span>
20408              <span class="fh-dir">pdf/</span>
20409            </div>
20410            <div class="fh-row fh-i2">
20411              <span class="fh-bul">&#8226;</span>
20412              <span class="fh-muted">report_*.pdf</span>
20413            </div>
20414            <div class="fh-row fh-i1">
20415              <span class="fh-tog">&#9658;</span>
20416              <span class="fh-dir">excel/</span>
20417            </div>
20418            <div class="fh-row fh-i2">
20419              <span class="fh-bul">&#8226;</span>
20420              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
20421            </div>
20422          </div>
20423        </div>
20424      </div>
20425    </div>
20426  </div>
20427  <footer class="site-footer">
20428    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
20429    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20430    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20431    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20432    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20433  </footer>
20434  <script nonce="{{ csp_nonce }}">(function(){
20435    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
20436    if(s==="dark")b.classList.add("dark-theme");
20437    document.getElementById("theme-toggle").addEventListener("click",function(){
20438      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
20439    });
20440  })();</script>
20441  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
20442    var c=document.getElementById('code-particles');if(!c)return;
20443    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'];
20444    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);}
20445  })();
20446  (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>
20447  <script nonce="{{ csp_nonce }}">(function(){
20448    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'}];
20449    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);});}
20450    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20451    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');});}
20452    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20453  }());</script>
20454  <script nonce="{{ csp_nonce }}">(function(){
20455    var meta=document.getElementById('locate-meta');
20456    var inp=document.getElementById('locate-file-input');
20457    var browseBtn=document.getElementById('browse-locate-btn');
20458    var submitBtn=document.getElementById('locate-submit-btn');
20459    var warning=document.getElementById('filename-warning');
20460    var errBox=document.getElementById('locate-error');
20461    var errText=document.getElementById('locate-error-text');
20462    var okBox=document.getElementById('locate-success');
20463    var expected=meta?meta.getAttribute('data-expected'):'';
20464    var runId=meta?meta.getAttribute('data-run-id'):'';
20465    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
20466    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
20467    function showErr(msg){
20468      if(errText){
20469        errText.innerHTML='';
20470        var lines=msg.split('\n');
20471        var hasPairs=lines.some(function(l){return / : /.test(l);});
20472        if(!hasPairs){errText.textContent=msg;}
20473        else{
20474          var frag=document.createDocumentFragment();var tbl=null;
20475          lines.forEach(function(line){
20476            var m=line.match(/^(.*?) : (.*)$/);
20477            if(m){
20478              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
20479              var tr=document.createElement('tr');
20480              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
20481              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
20482              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
20483            } else {
20484              tbl=null;
20485              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
20486            }
20487          });
20488          errText.appendChild(frag);
20489        }
20490      }
20491      if(errBox)errBox.classList.add('show');
20492      if(okBox)okBox.classList.remove('show');
20493    }
20494    function clearErr(){
20495      if(errBox)errBox.classList.remove('show');
20496      if(okBox)okBox.classList.remove('show');
20497    }
20498    function validate(){
20499      var val=inp?inp.value.trim():'';
20500      clearErr();
20501      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
20502      if(submitBtn)submitBtn.disabled=false;
20503      if(warning){
20504        var name=basename(val);
20505        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
20506        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
20507        else warning.classList.remove('show');
20508      }
20509    }
20510    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
20511    if(browseBtn){
20512      browseBtn.addEventListener('click',function(){
20513        browseBtn.disabled=true;browseBtn.textContent='...';
20514        fetch('/pick-directory')
20515          .then(function(r){return r.ok?r.json():{cancelled:true};})
20516          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
20517          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20518      });
20519    }
20520    if(submitBtn){
20521      submitBtn.addEventListener('click',function(){
20522        var folder=inp?inp.value.trim():'';
20523        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
20524        clearErr();
20525        submitBtn.disabled=true;submitBtn.textContent='Restoring…';
20526        var body=new URLSearchParams();
20527        body.set('file_path',folder);
20528        body.set('redirect_url',redirectUrl);
20529        body.set('expected_run_id',runId);
20530        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20531          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
20532          .then(function(d){
20533            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20534            if(d&&d.ok){
20535              if(okBox)okBox.classList.add('show');
20536              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
20537            } else {
20538              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
20539            }
20540          })
20541          .catch(function(e){
20542            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
20543            showErr('Network error: '+String(e));
20544          });
20545      });
20546    }
20547  })();</script>
20548  <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>
20549</body>
20550</html>
20551"##,
20552    ext = "html"
20553)]
20554struct LocateFileTemplate {
20555    run_id: String,
20556    artifact_type: String,
20557    expected_filename: String,
20558    server_mode: bool,
20559    csp_nonce: String,
20560    version: &'static str,
20561}
20562
20563// ── RelocateScanTemplate ──────────────────────────────────────────────────────
20564
20565#[derive(Template)]
20566#[template(
20567    source = r##"
20568<!doctype html>
20569<html lang="en">
20570<head>
20571  <meta charset="utf-8">
20572  <meta name="viewport" content="width=device-width, initial-scale=1">
20573  <title>OxideSLOC | Locate Scan Files</title>
20574  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20575  <style nonce="{{ csp_nonce }}">
20576    :root {
20577      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
20578      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20579      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
20580      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20581    }
20582    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
20583    *{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;}
20584    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20585    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20586    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
20587    .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);}
20588    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20589    .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));}
20590    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20591    .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;}
20592    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20593    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
20594    @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;}}
20595    .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;}
20596    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20597    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20598    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20599    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20600    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20601    .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;}
20602    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20603    .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);}
20604    .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;}
20605    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20606    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20607    .settings-modal-body{padding:14px 16px 16px;}
20608    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20609    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20610    .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;}
20611    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20612    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20613    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20614    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20615    .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;}
20616    .tz-select:focus{border-color:var(--oxide);}
20617    .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
20618    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
20619    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
20620    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
20621    .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;}
20622    .error-box.hidden{display:none;}
20623    .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;}
20624    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
20625    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
20626    .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;}
20627    .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;}
20628    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
20629    .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;}
20630    .btn-secondary:hover{background:var(--line);}
20631    .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;}
20632    .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;}
20633    .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;}
20634    @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));}}
20635    .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;}
20636    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
20637    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
20638    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
20639    .relocate-row{display:flex;gap:8px;align-items:stretch;}
20640    .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;}
20641    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
20642    body.dark-theme .relocate-input{background:var(--surface-2);}
20643  </style>
20644</head>
20645<body>
20646  <div class="background-watermarks" aria-hidden="true">
20647    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20648    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20649    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20650    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20651    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20652    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20653  </div>
20654  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20655  <div class="top-nav">
20656    <div class="top-nav-inner">
20657      <a class="brand" href="/">
20658        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
20659        <div class="brand-copy">
20660          <div class="brand-title">OxideSLOC</div>
20661          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
20662        </div>
20663      </a>
20664      <div class="nav-right">
20665        <a class="nav-pill" href="/">Home</a>
20666        <div class="nav-dropdown">
20667          <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>
20668          <div class="nav-dropdown-menu">
20669            <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>
20670          </div>
20671        </div>
20672        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
20673        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20674        <div class="nav-dropdown">
20675          <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>
20676          <div class="nav-dropdown-menu">
20677            <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>
20678          </div>
20679        </div>
20680        <div class="server-status-wrap" id="server-status-wrap">
20681          <div class="nav-pill server-online-pill" id="server-status-pill">
20682            <span class="status-dot" id="status-dot"></span>
20683            <span id="server-status-label">Server</span>
20684            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20685          </div>
20686          <div class="server-status-tip">
20687            OxideSLOC is running — accessible on your network.
20688            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20689          </div>
20690        </div>
20691        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20692          <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>
20693        </button>
20694        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20695          <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>
20696          <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>
20697        </button>
20698      </div>
20699    </div>
20700  </div>
20701
20702  <div class="page">
20703    <div class="panel">
20704      <h1>Scan Files Moved</h1>
20705      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
20706      <div class="error-box" id="relocate-error-box">{{ message }}</div>
20707      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
20708      <div class="relocate-section">
20709        <h2>Locate Scan Output</h2>
20710        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
20711        <div class="relocate-row">
20712          <input type="text" id="relocate-folder" name="folder_path"
20713                 value="{{ folder_hint }}"
20714                 placeholder="Path to folder containing scan output..."
20715                 class="relocate-input" autocomplete="off" spellcheck="false">
20716          {% if !server_mode %}
20717          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
20718          {% endif %}
20719        </div>
20720        <div style="margin-top:12px;">
20721          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
20722        </div>
20723      </div>
20724      <div class="actions">
20725        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
20726        <a class="btn-secondary" href="/view-reports">View Reports</a>
20727      </div>
20728    </div>
20729  </div>
20730  <footer class="site-footer">
20731    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
20732    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20733    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20734    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20735    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
20736  </footer>
20737  <script nonce="{{ csp_nonce }}">
20738    (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");});})();
20739    (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);}})();
20740    (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;});})();
20741  </script>
20742  <script nonce="{{ csp_nonce }}">
20743  (function(){
20744    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'}];
20745    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);});}
20746    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20747    function init(){
20748      var btn=document.getElementById('settings-btn');if(!btn)return;
20749      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20750      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>';
20751      document.body.appendChild(m);
20752      var g=document.getElementById('scheme-grid');
20753      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);});
20754      var cl=document.getElementById('settings-close');
20755      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);
20756      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');});
20757      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20758      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20759    }
20760    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20761  }());
20762  (function(){
20763    var browseBtn=document.getElementById('browse-relocate-btn');
20764    if(browseBtn){
20765      browseBtn.addEventListener('click',function(){
20766        browseBtn.disabled=true;browseBtn.textContent='...';
20767        var inp=document.getElementById('relocate-folder');
20768        var hint=inp?inp.value:'';
20769        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
20770          .then(function(r){return r.ok?r.json():{cancelled:true};})
20771          .then(function(d){
20772            browseBtn.disabled=false;browseBtn.textContent='Browse…';
20773            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
20774          })
20775          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
20776      });
20777    }
20778    var restoreBtn=document.getElementById('restore-btn');
20779    var errBox=document.getElementById('relocate-error-box');
20780    var okBox=document.getElementById('relocate-success-box');
20781    if(restoreBtn){
20782      restoreBtn.addEventListener('click',function(){
20783        var inp=document.getElementById('relocate-folder');
20784        var folder=inp?inp.value.trim():'';
20785        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
20786        restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
20787        var body=new URLSearchParams();
20788        body.set('run_id','{{ run_id }}');
20789        body.set('redirect_url','{{ redirect_url }}');
20790        body.set('folder_path',folder);
20791        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
20792          .then(function(r){return r.json();})
20793          .then(function(d){
20794            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
20795            if(d&&d.ok){
20796              if(errBox)errBox.classList.add('hidden');
20797              if(okBox){okBox.style.display='block';}
20798              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
20799            } else {
20800              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
20801            }
20802          })
20803          .catch(function(e){
20804            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
20805            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
20806          });
20807      });
20808    }
20809  }());
20810  </script>
20811  <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>
20812</body>
20813</html>
20814"##,
20815    ext = "html"
20816)]
20817struct RelocateScanTemplate {
20818    message: String,
20819    run_id: String,
20820    folder_hint: String,
20821    redirect_url: String,
20822    server_mode: bool,
20823    csp_nonce: String,
20824    version: &'static str,
20825}
20826
20827// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
20828
20829#[derive(Template)]
20830#[template(
20831    source = r##"
20832<!doctype html>
20833<html lang="en">
20834<head>
20835  <meta charset="utf-8">
20836  <meta name="viewport" content="width=device-width, initial-scale=1">
20837  <title>OxideSLOC | View Reports</title>
20838  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20839  <style nonce="{{ csp_nonce }}">
20840    :root {
20841      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
20842      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
20843      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
20844      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
20845      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
20846    }
20847    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; }
20848    *{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;}
20849    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
20850    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
20851    .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);}
20852    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
20853    .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));}
20854    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
20855    .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;}
20856    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
20857    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20858    @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; } }
20859    .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;}
20860    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
20861    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
20862    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
20863    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
20864    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
20865    .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;}
20866    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20867    .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);}
20868    .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;}
20869    .settings-close:hover{color:var(--text);background:var(--surface-2);}
20870    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20871    .settings-modal-body{padding:14px 16px 16px;}
20872    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20873    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20874    .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;}
20875    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20876    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20877    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20878    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20879    .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;}
20880    .tz-select:focus{border-color:var(--oxide);}
20881    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
20882    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
20883    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
20884    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
20885    .panel-meta{font-size:13px;color:var(--muted);}
20886    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
20887    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
20888    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
20889    .per-page-label{font-size:13px;color:var(--muted);}
20890    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;}
20891    .filter-input{min-width:180px;cursor:text;}
20892    .table-wrap{width:100%;overflow-x:auto;}
20893    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
20894    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;}
20895    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
20896    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
20897    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
20898    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
20899    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
20900    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20901    tr:last-child td{border-bottom:none;}
20902    tr:hover td{background:var(--surface-2);}
20903    .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);}
20904    .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);}
20905    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
20906    .metric-num{font-weight:700;color:var(--text);}
20907    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
20908    .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;}
20909    .btn:hover{background:var(--line);}
20910    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20911    .btn.primary:hover{opacity:.9;}
20912    .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;}
20913    .btn-back:hover{background:var(--line);}
20914    .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;}
20915    .export-btn:hover{background:var(--line);}
20916    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
20917    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
20918    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
20919    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
20920    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
20921    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
20922    .pagination-info{font-size:13px;color:var(--muted);}
20923    .pagination-btns{display:flex;gap:6px;}
20924    .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;}
20925    .pg-btn:hover:not(:disabled){background:var(--line);}
20926    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
20927    .pg-btn:disabled{opacity:.35;cursor:default;}
20928    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
20929    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20930    .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;}
20931    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
20932    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
20933    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
20934    .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);}
20935    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
20936    .stat-chip:hover .stat-chip-tip{opacity:1;}
20937    .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;}
20938    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20939    .site-footer a{color:var(--muted);}
20940    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
20941    .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%;}
20942    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
20943    .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;}
20944    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
20945    .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;}
20946    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
20947    .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;}
20948    .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;}
20949    .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;}
20950    @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));}}
20951    .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;}
20952    .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;}
20953    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
20954    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
20955    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
20956    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
20957    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
20958    .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;}
20959    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
20960    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
20961    .watched-chip-rm:hover{color:var(--oxide);}
20962    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
20963    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
20964    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
20965    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
20966    .rpt-btn{min-width:58px;justify-content:center;}
20967    .flex-row{display:flex;align-items:center;gap:8px;}
20968    .report-cell{overflow:visible;white-space:normal;}
20969    #history-table col:nth-child(1){width:185px;}
20970    #history-table col:nth-child(2){width:220px;}
20971    #history-table col:nth-child(3){width:100px;}
20972    #history-table col:nth-child(4){width:72px;}
20973    #history-table col:nth-child(5){width:82px;}
20974    #history-table col:nth-child(6){width:82px;}
20975    #history-table col:nth-child(7){width:65px;}
20976    #history-table col:nth-child(8){width:90px;}
20977    #history-table col:nth-child(9){width:85px;}
20978    #history-table col:nth-child(10){width:115px;}
20979    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
20980    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
20981    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
20982    .submod-details summary::-webkit-details-marker{display:none;}
20983.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
20984    .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;}
20985    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
20986    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
20987  </style>
20988</head>
20989<body>
20990  <div class="background-watermarks" aria-hidden="true">
20991    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20992    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20993    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20994    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20995    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20996    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
20997  </div>
20998  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20999  <div class="top-nav">
21000    <div class="top-nav-inner">
21001      <a class="brand" href="/">
21002        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21003        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
21004      </a>
21005      <div class="nav-right">
21006        <a class="nav-pill" href="/">Home</a>
21007        <div class="nav-dropdown">
21008          <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>
21009          <div class="nav-dropdown-menu">
21010            <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>
21011          </div>
21012        </div>
21013        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21014        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21015        <div class="nav-dropdown">
21016          <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>
21017          <div class="nav-dropdown-menu">
21018            <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>
21019          </div>
21020        </div>
21021        <div class="server-status-wrap" id="server-status-wrap">
21022          <div class="nav-pill server-online-pill" id="server-status-pill">
21023            <span class="status-dot" id="status-dot"></span>
21024            <span id="server-status-label">Server</span>
21025            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21026          </div>
21027          <div class="server-status-tip">
21028            OxideSLOC is running — accessible on your network.
21029            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21030          </div>
21031        </div>
21032        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21033          <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>
21034        </button>
21035        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21036          <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>
21037          <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>
21038        </button>
21039      </div>
21040    </div>
21041  </div>
21042
21043  <div class="page">
21044    {% if let Some(err) = browse_error %}
21045    <div class="toast-error">
21046      <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>
21047      {{ err }}
21048    </div>
21049    {% endif %}
21050    {% if linked_count > 0 %}
21051    <div class="toast-success">
21052      <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>
21053      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
21054    </div>
21055    {% endif %}
21056    <div class="watched-bar">
21057      <div class="watched-bar-left">
21058        <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>
21059        <span class="watched-label">Watched Folders</span>
21060        <div class="watched-chips">
21061          {% if server_mode %}
21062          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21063          {% else %}
21064          {% for dir in watched_dirs %}
21065          <span class="watched-chip">
21066            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21067            <form method="POST" action="/watched-dirs/remove" style="display:contents">
21068              <input type="hidden" name="folder_path" value="{{ dir }}">
21069              <input type="hidden" name="redirect_to" value="/view-reports">
21070              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
21071            </form>
21072          </span>
21073          {% endfor %}
21074          {% if watched_dirs.is_empty() %}
21075          <span class="watched-none">No folders watched — click Choose to add one</span>
21076          {% endif %}
21077          {% endif %}
21078        </div>
21079      </div>
21080      {% if !server_mode %}
21081      <div class="watched-bar-right">
21082        <button type="button" class="btn" id="add-watched-btn">
21083          <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>
21084          Choose
21085        </button>
21086        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21087          <input type="hidden" name="redirect_to" value="/view-reports">
21088          <button type="submit" class="btn">&#8635; Refresh</button>
21089        </form>
21090      </div>
21091      {% endif %}
21092    </div>
21093    {% if total_scans > 0 %}
21094    <div class="summary-strip">
21095      <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>
21096      <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>
21097      <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>
21098      <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>
21099    </div>
21100    {% endif %}
21101
21102    <section class="panel">
21103      <div class="panel-header">
21104        <div>
21105          <h1>View Reports</h1>
21106          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
21107          {% 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 %}
21108        </div>
21109        <div class="flex-row">
21110          <button type="button" class="export-btn" id="export-csv-btn">
21111            <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>
21112            Export CSV
21113          </button>
21114          <button type="button" class="export-btn" id="export-xls-btn">
21115            <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>
21116            Export Excel
21117          </button>
21118        </div>
21119      </div>
21120
21121      {% if entries.is_empty() %}
21122      <div class="empty-state">
21123        <strong>No reports with viewable HTML yet</strong>
21124        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.
21125      </div>
21126      {% else %}
21127      <div class="filter-row">
21128        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
21129        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21130        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
21131      </div>
21132      <div class="table-wrap">
21133        <table id="history-table">
21134          <colgroup>
21135            <col><col><col><col><col><col><col><col><col><col>
21136          </colgroup>
21137          <thead>
21138            <tr id="history-thead">
21139              <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>
21140              <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>
21141              <th>Run ID<div class="col-resize-handle"></div></th>
21142              <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>
21143              <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>
21144              <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>
21145              <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>
21146              <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>
21147              <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>
21148              <th>Report<div class="col-resize-handle"></div></th>
21149            </tr>
21150          </thead>
21151          <tbody id="history-tbody">
21152            {% for entry in entries %}
21153            <tr class="history-row" data-run="{{ entry.run_id }}"
21154                data-timestamp="{{ entry.timestamp }}"
21155                data-project="{{ entry.project_label }}"
21156                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
21157                data-skipped="{{ entry.files_skipped }}"
21158                data-comments="{{ entry.comment_lines }}"
21159                data-blank="{{ entry.blank_lines }}"
21160                data-branch="{{ entry.git_branch }}"
21161                data-commit="{{ entry.git_commit }}"
21162                data-html-url="/runs/html/{{ entry.run_id }}">
21163              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21164              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21165              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
21166              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
21167              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21168              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21169              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21170              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
21171              <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>
21172              <td class="report-cell">
21173                <div class="actions-cell">
21174                  {% 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 %}
21175                  {% 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 %}
21176                </div>
21177                {% if !entry.submodule_links.is_empty() %}
21178                <details class="submod-details">
21179                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
21180                  <div class="submod-link-list">
21181                    {% for sub in entry.submodule_links %}
21182                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
21183                    {% endfor %}
21184                  </div>
21185                </details>
21186                {% endif %}
21187              </td>
21188            </tr>
21189            {% endfor %}
21190          </tbody>
21191        </table>
21192      </div>
21193      <div class="pagination">
21194        <span class="pagination-info" id="pagination-info"></span>
21195        <div class="pagination-btns" id="pagination-btns"></div>
21196        <div class="flex-row">
21197          <span class="per-page-label">Show</span>
21198          <select class="per-page" id="per-page-sel">
21199            <option value="10">10 per page</option>
21200            <option value="25" selected>25 per page</option>
21201            <option value="50">50 per page</option>
21202            <option value="100">100 per page</option>
21203          </select>
21204          <span class="per-page-label" id="page-range-label"></span>
21205        </div>
21206      </div>
21207      {% endif %}
21208    </section>
21209  </div>
21210
21211  <footer class="site-footer">
21212    local code analysis - metrics, history and reports
21213    &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>
21214    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21215    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21216    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21217    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21218  </footer>
21219
21220  <script nonce="{{ csp_nonce }}">
21221    (function () {
21222      // ── Theme ──────────────────────────────────────────────────────────────
21223      var storageKey = 'oxide-sloc-theme';
21224      var body = document.body;
21225      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21226      var toggle = document.getElementById('theme-toggle');
21227      if (toggle) toggle.addEventListener('click', function () {
21228        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21229        body.classList.toggle('dark-theme', next === 'dark');
21230        try { localStorage.setItem(storageKey, next); } catch(e) {}
21231      });
21232
21233      // ── State ─────────────────────────────────────────────────────────────
21234      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
21235      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
21236      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21237
21238      // Aggregate stats from first (most recent) row
21239      if (allRows.length) {
21240        var first = allRows[0];
21241        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();}
21242        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>':'');}
21243        setChipVal('agg-code', first.dataset.code);
21244        setChipVal('agg-files', first.dataset.files);
21245        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
21246        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
21247      }
21248
21249      // ── Branch filter population ──────────────────────────────────────────
21250      (function() {
21251        var branches = {};
21252        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21253        var sel = document.getElementById('branch-filter');
21254        if (sel) Object.keys(branches).sort().forEach(function(b) {
21255          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21256        });
21257      })();
21258
21259      // ── Filter ────────────────────────────────────────────────────────────
21260      function getFilteredRows() {
21261        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21262        var branch = ((document.getElementById('branch-filter') || {}).value || '');
21263        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
21264          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21265          if (branch && (r.dataset.branch || '') !== branch) return false;
21266          return true;
21267        });
21268      }
21269
21270      // ── Pagination ────────────────────────────────────────────────────────
21271      function renderPage() {
21272        var filtered = getFilteredRows();
21273        var total = filtered.length;
21274        var totalPages = Math.max(1, Math.ceil(total / perPage));
21275        currentPage = Math.min(currentPage, totalPages);
21276        var start = (currentPage - 1) * perPage;
21277        var end = Math.min(start + perPage, total);
21278        var shown = {};
21279        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21280        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
21281          r.style.display = shown[r.dataset.run] ? '' : 'none';
21282        });
21283        var rl = document.getElementById('page-range-label');
21284        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21285        var info = document.getElementById('pagination-info');
21286        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21287        var btns = document.getElementById('pagination-btns');
21288        if (!btns) return;
21289        btns.innerHTML = '';
21290        function makeBtn(lbl, pg, active, disabled) {
21291          var b = document.createElement('button');
21292          b.className = 'pg-btn' + (active ? ' active' : '');
21293          b.textContent = lbl; b.disabled = disabled;
21294          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
21295          return b;
21296        }
21297        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
21298        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
21299        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
21300        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
21301      }
21302
21303      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
21304      window.applyFilters = function() { currentPage = 1; renderPage(); };
21305
21306      // ── Sorting ───────────────────────────────────────────────────────────
21307      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
21308      function doSort(col, type, order) {
21309        var tbody = document.getElementById('history-tbody');
21310        if (!tbody) return;
21311        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21312        rows.sort(function(a, b) {
21313          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
21314          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
21315          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
21316          return va < vb ? 1 : va > vb ? -1 : 0;
21317        });
21318        rows.forEach(function(r) { tbody.appendChild(r); });
21319        currentPage = 1; renderPage();
21320      }
21321      sortHeaders.forEach(function(th) {
21322        th.addEventListener('click', function(e) {
21323          if (e.target.classList.contains('col-resize-handle')) return;
21324          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
21325          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
21326          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21327          th.classList.add('sort-' + sortOrder);
21328          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
21329          doSort(col, type, sortOrder);
21330        });
21331      });
21332
21333      // ── Column resize ─────────────────────────────────────────────────────
21334      (function() {
21335        var table = document.getElementById('history-table');
21336        if (!table) return;
21337        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
21338        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
21339        ths.forEach(function(th, i) {
21340          var handle = th.querySelector('.col-resize-handle');
21341          if (!handle || !cols[i]) return;
21342          var startX, startW;
21343          handle.addEventListener('mousedown', function(e) {
21344            e.stopPropagation(); e.preventDefault();
21345            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
21346            handle.classList.add('dragging');
21347            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
21348            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
21349            document.addEventListener('mousemove', onMove);
21350            document.addEventListener('mouseup', onUp);
21351          });
21352        });
21353      })();
21354
21355      // ── Reset view ────────────────────────────────────────────────────────
21356      window.resetView = function() {
21357        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
21358        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
21359        sortCol = null; sortOrder = 'asc';
21360        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
21361        var tbody = document.getElementById('history-tbody');
21362        if (tbody) {
21363          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
21364          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
21365          rows.forEach(function(r) { tbody.appendChild(r); });
21366        }
21367        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
21368        var table = document.getElementById('history-table');
21369        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
21370        currentPage = 1; renderPage();
21371      };
21372
21373      renderPage();
21374
21375      // ── Export helpers ────────────────────────────────────────────────────
21376      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
21377      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
21378      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);}
21379      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;');}
21380      function slocXlsx(fname,sheet,hdrs,rows){
21381        var enc=new TextEncoder();
21382        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;}
21383        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;}
21384        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
21385        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
21386        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
21387        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;}
21388        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];}
21389        var rx='<row r="1">';
21390        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
21391        rx+='</row>';
21392        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>';});
21393        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
21394        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>';
21395        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>';
21396        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>';
21397        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>',
21398          '_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>',
21399          '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>',
21400          '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>',
21401          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
21402        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'];
21403        var zparts=[],zcds=[],zoff=0,znf=0;
21404        order.forEach(function(name){
21405          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
21406          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]);
21407          var entry=new Uint8Array(lha.length+nb.length+sz);
21408          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
21409          zparts.push(entry);
21410          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));
21411          var cde=new Uint8Array(cda.length+nb.length);
21412          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
21413          zcds.push(cde);zoff+=entry.length;znf++;
21414        });
21415        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
21416        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]);
21417        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
21418        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
21419        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
21420        zout.set(new Uint8Array(ea),zpos);
21421        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
21422      }
21423
21424      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
21425      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;}
21426      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
21427      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
21428
21429      var csvBtn = document.getElementById('export-csv-btn');
21430      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
21431      var xlsBtn = document.getElementById('export-xls-btn');
21432      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
21433
21434      // ── Remaining CSP-safe event bindings ────────────────────────────────
21435      (function wireEvents() {
21436        var el;
21437        el = document.getElementById('reset-view-btn');
21438        if (el) el.addEventListener('click', window.resetView);
21439        el = document.getElementById('project-filter');
21440        if (el) el.addEventListener('input', window.applyFilters);
21441        el = document.getElementById('branch-filter');
21442        if (el) el.addEventListener('change', window.applyFilters);
21443        el = document.getElementById('per-page-sel');
21444        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
21445        el = document.getElementById('add-watched-btn');
21446        if (el) el.addEventListener('click', function() {
21447          fetch('/pick-directory?kind=reports')
21448            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
21449            .then(function(data) {
21450              if (!data.cancelled && data.selected_path) {
21451                var form = document.createElement('form');
21452                form.method = 'POST';
21453                form.action = '/watched-dirs/add';
21454                var ri = document.createElement('input');
21455                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
21456                var fi = document.createElement('input');
21457                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
21458                form.appendChild(ri); form.appendChild(fi);
21459                document.body.appendChild(form);
21460                form.submit();
21461              }
21462            })
21463            .catch(function(e) { alert('Could not open folder picker: ' + e); });
21464        });
21465      })();
21466
21467      (function randomizeWatermarks() {
21468        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21469        if (!wms.length) return;
21470        var placed = [];
21471        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;}
21472        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];}
21473        var half=Math.floor(wms.length/2);
21474        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;});
21475      })();
21476
21477      (function spawnCodeParticles() {
21478        var container = document.getElementById('code-particles');
21479        if (!container) return;
21480        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'];
21481        for (var i = 0; i < 38; i++) {
21482          (function(idx) {
21483            var el = document.createElement('span');
21484            el.className = 'code-particle';
21485            el.textContent = snippets[idx % snippets.length];
21486            var left = Math.random() * 94 + 2;
21487            var top = Math.random() * 88 + 6;
21488            var dur = (Math.random() * 10 + 9).toFixed(1);
21489            var delay = (Math.random() * 18).toFixed(1);
21490            var rot = (Math.random() * 26 - 13).toFixed(1);
21491            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21492            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';
21493            container.appendChild(el);
21494          })(i);
21495        }
21496      })();
21497    })();
21498  </script>
21499  <script nonce="{{ csp_nonce }}">
21500  (function(){
21501    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'}];
21502    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);});}
21503    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21504    function init(){
21505      var btn=document.getElementById('settings-btn');if(!btn)return;
21506      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21507      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>';
21508      document.body.appendChild(m);
21509      var g=document.getElementById('scheme-grid');
21510      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);});
21511      var cl=document.getElementById('settings-close');
21512      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);
21513      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');});
21514      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21515      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21516    }
21517    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21518  }());
21519  </script>
21520  <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>
21521</body>
21522</html>
21523"##,
21524    ext = "html"
21525)]
21526struct HistoryTemplate {
21527    version: &'static str,
21528    entries: Vec<HistoryEntryRow>,
21529    total_scans: usize,
21530    linked_count: usize,
21531    browse_error: Option<String>,
21532    watched_dirs: Vec<String>,
21533    csp_nonce: String,
21534    server_mode: bool,
21535}
21536
21537// ── CompareSelectTemplate ──────────────────────────────────────────────────────
21538
21539#[derive(Template)]
21540#[template(
21541    source = r##"
21542<!doctype html>
21543<html lang="en">
21544<head>
21545  <meta charset="utf-8">
21546  <meta name="viewport" content="width=device-width, initial-scale=1">
21547  <title>OxideSLOC | Compare Scans</title>
21548  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21549  <style nonce="{{ csp_nonce }}">
21550    :root {
21551      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
21552      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21553      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21554      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21555      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
21556    }
21557    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
21558    *{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;}
21559    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21560    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21561    .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);}
21562    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21563    .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));}
21564    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21565    .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;}
21566    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21567    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21568    @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; } }
21569    .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;}
21570    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21571    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
21572    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21573    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21574    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21575    .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;}
21576    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21577    .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);}
21578    .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;}
21579    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21580    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21581    .settings-modal-body{padding:14px 16px 16px;}
21582    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21583    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21584    .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;}
21585    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21586    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21587    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21588    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21589    .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;}
21590    .tz-select:focus{border-color:var(--oxide);}
21591    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
21592    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
21593    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
21594    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
21595    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
21596    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
21597    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
21598    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
21599    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
21600    .per-page-label{font-size:13px;color:var(--muted);}
21601    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;}
21602    .filter-input{min-width:180px;cursor:text;}
21603    .table-wrap{width:100%;overflow-x:auto;}
21604    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
21605    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;}
21606    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
21607    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
21608    #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;}
21609    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
21610    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
21611    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
21612    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
21613    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
21614    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
21615    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
21616    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
21617    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
21618    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
21619    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
21620    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
21621    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21622    tr:last-child td{border-bottom:none;}
21623    tr.selected td{background:var(--sel-bg);}
21624    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
21625    tr:hover:not(.selected) td{background:var(--surface-2);}
21626    tr{cursor:pointer;}
21627    .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);}
21628    .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);}
21629    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
21630    .metric-num{font-weight:700;color:var(--text);}
21631    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
21632    .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;}
21633    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
21634    .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;}
21635    .btn:hover{background:var(--line);}
21636    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
21637    .btn.primary:hover{opacity:.9;}
21638    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
21639    .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;}
21640    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
21641    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
21642    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
21643    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
21644    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
21645    .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;}
21646    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
21647    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
21648    .watched-chip-rm:hover{color:var(--oxide);}
21649    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
21650    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
21651    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
21652    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
21653    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
21654    .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;}
21655    .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;}
21656    .btn-back:hover{background:var(--line);}
21657    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
21658    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
21659    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
21660    .pagination-info{font-size:13px;color:var(--muted);}
21661    .pagination-btns{display:flex;gap:6px;}
21662    .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;}
21663    .pg-btn:hover:not(:disabled){background:var(--line);}
21664    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
21665    .pg-btn:disabled{opacity:.35;cursor:default;}
21666    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
21667    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21668    .site-footer a{color:var(--muted);}
21669    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
21670    .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;}
21671    .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;}
21672    .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;}
21673    @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));}}
21674    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
21675    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
21676    .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;}
21677    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
21678    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
21679    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
21680    .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);}
21681    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
21682    .stat-chip:hover .stat-chip-tip{opacity:1;}
21683    .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;}
21684    .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;}
21685    .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%;}
21686    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
21687    .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;}
21688    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
21689    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
21690    .hidden{display:none!important;}
21691    .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%;}
21692    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
21693    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
21694    .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;}
21695    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
21696    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
21697    .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;}
21698    .scope-option:hover{background:var(--line);}
21699    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
21700    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
21701    .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;}
21702    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
21703    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
21704    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
21705    .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;}
21706  </style>
21707</head>
21708<body>
21709  <div class="background-watermarks" aria-hidden="true">
21710    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21711    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21712    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21713    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21714    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21715    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21716  </div>
21717  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21718  <div class="top-nav">
21719    <div class="top-nav-inner">
21720      <a class="brand" href="/">
21721        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21722        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
21723      </a>
21724      <div class="nav-right">
21725        <a class="nav-pill" href="/">Home</a>
21726        <div class="nav-dropdown">
21727          <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>
21728          <div class="nav-dropdown-menu">
21729            <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>
21730          </div>
21731        </div>
21732        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21733        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21734        <div class="nav-dropdown">
21735          <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>
21736          <div class="nav-dropdown-menu">
21737            <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>
21738          </div>
21739        </div>
21740        <div class="server-status-wrap" id="server-status-wrap">
21741          <div class="nav-pill server-online-pill" id="server-status-pill">
21742            <span class="status-dot" id="status-dot"></span>
21743            <span id="server-status-label">Server</span>
21744            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21745          </div>
21746          <div class="server-status-tip">
21747            OxideSLOC is running — accessible on your network.
21748            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21749          </div>
21750        </div>
21751        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21752          <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>
21753        </button>
21754        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21755          <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>
21756          <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>
21757        </button>
21758      </div>
21759    </div>
21760  </div>
21761
21762  <div class="page">
21763    <div class="watched-bar">
21764      <div class="watched-bar-left">
21765        <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>
21766        <span class="watched-label">Watched Folders</span>
21767        <div class="watched-chips">
21768          {% if server_mode %}
21769          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
21770          {% else %}
21771          {% for dir in watched_dirs %}
21772          <span class="watched-chip">
21773            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
21774            <form method="POST" action="/watched-dirs/remove" style="display:contents">
21775              <input type="hidden" name="folder_path" value="{{ dir }}">
21776              <input type="hidden" name="redirect_to" value="/compare-scans">
21777              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
21778            </form>
21779          </span>
21780          {% endfor %}
21781          {% if watched_dirs.is_empty() %}
21782          <span class="watched-none">No folders watched — click Choose to add one</span>
21783          {% endif %}
21784          {% endif %}
21785        </div>
21786      </div>
21787      {% if !server_mode %}
21788      <div class="watched-bar-right">
21789        <button type="button" class="btn" id="add-watched-btn">
21790          <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>
21791          Choose
21792        </button>
21793        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
21794          <input type="hidden" name="redirect_to" value="/compare-scans">
21795          <button type="submit" class="btn">&#8635; Refresh</button>
21796        </form>
21797      </div>
21798      {% endif %}
21799    </div>
21800    {% if total_scans > 0 %}
21801    <div class="summary-strip">
21802      <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>
21803      <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>
21804      <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>
21805      <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>
21806    </div>
21807    {% endif %}
21808    <section class="panel">
21809      <div class="panel-header">
21810        <div>
21811          <h1>Compare Scans</h1>
21812          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
21813        </div>
21814        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
21815          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
21816            <button class="btn primary" id="compare-btn" disabled>
21817              <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>
21818              Compare <span class="sel-count" id="sel-count">0/2</span>
21819            </button>
21820          </div>
21821        </div>
21822      </div>
21823
21824      {% if entries.is_empty() %}
21825      <div class="empty-state">
21826        <strong>No scans yet</strong>
21827        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.
21828      </div>
21829      {% else %}
21830      <div class="filter-row">
21831        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
21832        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
21833        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
21834      </div>
21835      <div class="scope-panel hidden" id="scope-panel">
21836        <div class="scope-panel-label">
21837          <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>
21838          Compare scope — choose what to include
21839        </div>
21840        <div class="scope-options" id="scope-options"></div>
21841      </div>
21842      {% if total_scans > 0 %}
21843      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
21844        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
21845          <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>
21846          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
21847        </div>
21848      </div>
21849      {% endif %}
21850      <div class="table-wrap">
21851        <table id="compare-table">
21852          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
21853          <thead>
21854            <tr id="compare-thead">
21855              <th><div class="col-resize-handle"></div></th>
21856              <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>
21857              <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>
21858              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
21859              <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>
21860              <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>
21861              <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>
21862              <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>
21863              <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>
21864              <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>
21865              <th>Submodules<div class="col-resize-handle"></div></th>
21866            </tr>
21867          </thead>
21868          <tbody id="compare-tbody">
21869            {% for entry in entries %}
21870            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
21871                data-timestamp="{{ entry.timestamp }}"
21872                data-project="{{ entry.project_label }}"
21873                data-files="{{ entry.files_analyzed }}"
21874                data-code="{{ entry.code_lines }}"
21875                data-comments="{{ entry.comment_lines }}"
21876                data-blank="{{ entry.blank_lines }}"
21877                data-branch="{{ entry.git_branch }}"
21878                data-commit="{{ entry.git_commit }}"
21879                data-submodules="{{ entry.submodule_names_csv }}">
21880              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
21881              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
21882              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
21883              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
21884              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
21885              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
21886              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
21887              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
21888              <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>
21889              <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>
21890              <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>
21891            </tr>
21892            {% endfor %}
21893          </tbody>
21894        </table>
21895      </div>
21896      <div class="pagination">
21897        <span class="pagination-info" id="pagination-info"></span>
21898        <div class="pagination-btns" id="pagination-btns"></div>
21899        <div class="flex-row">
21900          <span class="per-page-label">Show</span>
21901          <select class="per-page" id="per-page-sel">
21902            <option value="10">10 per page</option>
21903            <option value="25" selected>25 per page</option>
21904            <option value="50">50 per page</option>
21905            <option value="100">100 per page</option>
21906          </select>
21907          <span class="per-page-label" id="page-range-label"></span>
21908        </div>
21909      </div>
21910      {% endif %}
21911    </section>
21912  </div>
21913
21914  <footer class="site-footer">
21915    local code analysis - metrics, history and reports
21916    &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>
21917    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21918    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21919    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21920    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21921  </footer>
21922
21923  <script nonce="{{ csp_nonce }}">
21924    (function () {
21925      // ── Theme ──────────────────────────────────────────────────────────────
21926      var storageKey = 'oxide-sloc-theme';
21927      var body = document.body;
21928      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21929      var toggle = document.getElementById('theme-toggle');
21930      if (toggle) toggle.addEventListener('click', function () {
21931        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21932        body.classList.toggle('dark-theme', next === 'dark');
21933        try { localStorage.setItem(storageKey, next); } catch(e) {}
21934      });
21935
21936      // ── State ─────────────────────────────────────────────────────────────
21937      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
21938      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
21939      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
21940
21941      // ── Stat chips ────────────────────────────────────────────────────────
21942      (function() {
21943        var projects = {}, latestTs = '', latestRow = null;
21944        allRows.forEach(function(r) {
21945          var p = r.dataset.project || ''; if (p) projects[p] = true;
21946          var ts = r.dataset.timestamp || '';
21947          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
21948        });
21949        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();}
21950        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>':'');}
21951        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
21952        if (latestRow) {
21953          setChipVal('agg-code', latestRow.dataset.code);
21954          setChipVal('agg-files', latestRow.dataset.files);
21955        }
21956      })();
21957
21958      // ── Branch filter population ──────────────────────────────────────────
21959      (function() {
21960        var branches = {};
21961        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
21962        var sel = document.getElementById('branch-filter');
21963        if (sel) Object.keys(branches).sort().forEach(function(b) {
21964          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
21965        });
21966      })();
21967
21968      // ── Filter ────────────────────────────────────────────────────────────
21969      function getFilteredRows() {
21970        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
21971        var branch = ((document.getElementById('branch-filter') || {}).value || '');
21972        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
21973          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
21974          if (branch && (r.dataset.branch || '') !== branch) return false;
21975          return true;
21976        });
21977      }
21978
21979      // ── Pagination ────────────────────────────────────────────────────────
21980      function renderPage() {
21981        var filtered = getFilteredRows();
21982        var total = filtered.length;
21983        var totalPages = Math.max(1, Math.ceil(total / perPage));
21984        currentPage = Math.min(currentPage, totalPages);
21985        var start = (currentPage - 1) * perPage;
21986        var end = Math.min(start + perPage, total);
21987        var shown = {};
21988        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
21989        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
21990          r.style.display = shown[r.dataset.run] ? '' : 'none';
21991        });
21992        var rl = document.getElementById('page-range-label');
21993        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
21994        var info = document.getElementById('pagination-info');
21995        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
21996        var btns = document.getElementById('pagination-btns');
21997        if (!btns) return;
21998        btns.innerHTML = '';
21999        function makeBtn(lbl, pg, active, disabled) {
22000          var b = document.createElement('button');
22001          b.className = 'pg-btn' + (active ? ' active' : '');
22002          b.textContent = lbl; b.disabled = disabled;
22003          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
22004          return b;
22005        }
22006        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
22007        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22008        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
22009        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
22010      }
22011
22012      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
22013      window.applyFilters = function() { currentPage = 1; renderPage(); };
22014
22015      // ── Sorting ───────────────────────────────────────────────────────────
22016      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
22017      function doSort(col, type, order) {
22018        var tbody = document.getElementById('compare-tbody');
22019        if (!tbody) return;
22020        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22021        rows.sort(function(a, b) {
22022          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
22023          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
22024          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
22025          return va < vb ? 1 : va > vb ? -1 : 0;
22026        });
22027        rows.forEach(function(r) { tbody.appendChild(r); });
22028        currentPage = 1; renderPage();
22029      }
22030      sortHeaders.forEach(function(th) {
22031        th.addEventListener('click', function(e) {
22032          if (e.target.classList.contains('col-resize-handle')) return;
22033          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
22034          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
22035          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22036          th.classList.add('sort-' + sortOrder);
22037          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
22038          doSort(col, type, sortOrder);
22039        });
22040      });
22041
22042      // Apply default sort (timestamp desc) on initial load
22043      (function() {
22044        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
22045        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
22046      })();
22047
22048      // ── Column resize ─────────────────────────────────────────────────────
22049      (function() {
22050        var table = document.getElementById('compare-table');
22051        if (!table) return;
22052        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
22053        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
22054        ths.forEach(function(th, i) {
22055          var handle = th.querySelector('.col-resize-handle');
22056          if (!handle || !cols[i]) return;
22057          var startX, startW;
22058          handle.addEventListener('mousedown', function(e) {
22059            e.stopPropagation(); e.preventDefault();
22060            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
22061            handle.classList.add('dragging');
22062            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
22063            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
22064            document.addEventListener('mousemove', onMove);
22065            document.addEventListener('mouseup', onUp);
22066          });
22067        });
22068      })();
22069
22070      // ── Reset view ────────────────────────────────────────────────────────
22071      window.resetView = function() {
22072        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
22073        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
22074        sortCol = null; sortOrder = 'asc';
22075        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
22076        var tbody = document.getElementById('compare-tbody');
22077        if (tbody) {
22078          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
22079          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
22080          rows.forEach(function(r) { tbody.appendChild(r); });
22081        }
22082        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
22083        var table = document.getElementById('compare-table');
22084        currentPage = 1; renderPage();
22085        currentPage = 1; renderPage();
22086      };
22087
22088      renderPage();
22089
22090      // ── Row selection state ───────────────────────────────────────────────
22091      var selected = [];
22092      function updateCompareBtn() {
22093        var btn = document.getElementById('compare-btn');
22094        var cnt = document.getElementById('sel-count');
22095        if (!btn) return;
22096        btn.disabled = selected.length !== 2;
22097        if (cnt) cnt.textContent = selected.length + '/2';
22098      }
22099
22100      function toggleRow(row) {
22101        var vid = row.dataset.vid || row.dataset.run;
22102        var idx = selected.indexOf(vid);
22103        if (idx >= 0) {
22104          selected.splice(idx, 1);
22105          row.classList.remove('selected');
22106          var b = document.getElementById('badge-' + vid);
22107          if (b) b.textContent = '';
22108        } else {
22109          if (selected.length >= 2) return;
22110          selected.push(vid);
22111          row.classList.add('selected');
22112        }
22113        selected.forEach(function(v, i) {
22114          var b = document.getElementById('badge-' + v);
22115          if (b) b.textContent = i + 1;
22116        });
22117        updateCompareBtn();
22118        buildScopePanel();
22119      }
22120
22121      // ── Scope panel ───────────────────────────────────────────────────────
22122      var selectedScope = 'all';
22123
22124      function buildScopePanel() {
22125        var panel = document.getElementById('scope-panel');
22126        var opts = document.getElementById('scope-options');
22127        if (!panel || !opts) return;
22128        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22129
22130        // Collect union of submodules from both selected rows.
22131        var allSubs = {};
22132        selected.forEach(function(vid) {
22133          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
22134          if (!row) return;
22135          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
22136        });
22137        var subList = Object.keys(allSubs).sort();
22138        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
22139
22140        panel.classList.remove('hidden');
22141        opts.innerHTML = '';
22142
22143        function makeOption(value, label, title) {
22144          var div = document.createElement('div');
22145          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
22146          div.dataset.scopeValue = value;
22147          if (title) div.title = title;
22148          var radio = document.createElement('span');
22149          radio.className = 'scope-option-radio';
22150          var lbl = document.createElement('span');
22151          lbl.textContent = label;
22152          div.appendChild(radio);
22153          div.appendChild(lbl);
22154          div.addEventListener('click', function() {
22155            selectedScope = value;
22156            opts.querySelectorAll('.scope-option').forEach(function(o) {
22157              o.classList.toggle('selected', o.dataset.scopeValue === value);
22158            });
22159          });
22160          return div;
22161        }
22162
22163        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
22164        var sep = document.createElement('span');
22165        sep.className = 'scope-option-sep';
22166        opts.appendChild(sep);
22167        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
22168        subList.forEach(function(s) {
22169          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
22170        });
22171      }
22172
22173      function doCompare() {
22174        if (selected.length !== 2) return;
22175        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
22176        if (selectedScope === 'super') url += '&scope=super';
22177        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
22178        window.location.href = url;
22179      }
22180
22181      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
22182      var cbtn = document.getElementById('compare-btn');
22183      if (cbtn) cbtn.addEventListener('click', doCompare);
22184      var pfEl = document.getElementById('project-filter');
22185      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
22186      var bfEl = document.getElementById('branch-filter');
22187      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
22188      var rvBtn = document.getElementById('reset-view-btn');
22189      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
22190      var ppSel = document.getElementById('per-page-sel');
22191      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
22192
22193      var cmpTbody = document.getElementById('compare-tbody');
22194      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
22195        var row = e.target.closest('.compare-row');
22196        if (row) toggleRow(row);
22197      });
22198
22199      (function randomizeWatermarks() {
22200        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22201        if (!wms.length) return;
22202        var placed = [];
22203        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;}
22204        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];}
22205        var half=Math.floor(wms.length/2);
22206        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;});
22207      })();
22208
22209      (function spawnCodeParticles() {
22210        var container = document.getElementById('code-particles');
22211        if (!container) return;
22212        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'];
22213        for (var i = 0; i < 38; i++) {
22214          (function(idx) {
22215            var el = document.createElement('span');
22216            el.className = 'code-particle';
22217            el.textContent = snippets[idx % snippets.length];
22218            var left = Math.random() * 94 + 2;
22219            var top = Math.random() * 88 + 6;
22220            var dur = (Math.random() * 10 + 9).toFixed(1);
22221            var delay = (Math.random() * 18).toFixed(1);
22222            var rot = (Math.random() * 26 - 13).toFixed(1);
22223            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22224            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';
22225            container.appendChild(el);
22226          })(i);
22227        }
22228      })();
22229
22230      // ── Watched folder picker ─────────────────────────────────────────────
22231      (function() {
22232        var btn = document.getElementById('add-watched-btn');
22233        if (!btn) return;
22234        btn.addEventListener('click', function() {
22235          fetch('/pick-directory?kind=reports')
22236            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
22237            .then(function(data) {
22238              if (!data.cancelled && data.selected_path) {
22239                var form = document.createElement('form');
22240                form.method = 'POST';
22241                form.action = '/watched-dirs/add';
22242                var ri = document.createElement('input');
22243                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
22244                var fi = document.createElement('input');
22245                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
22246                form.appendChild(ri); form.appendChild(fi);
22247                document.body.appendChild(form);
22248                form.submit();
22249              }
22250            })
22251            .catch(function(e) { alert('Could not open folder picker: ' + e); });
22252        });
22253      })();
22254
22255      // ── Submodule chip truncation ─────────────────────────────────────────
22256      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
22257        var chips = cell.querySelectorAll('.submod-chip');
22258        var MAX = 4;
22259        if (chips.length <= MAX) return;
22260        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
22261        var badge = document.createElement('span');
22262        badge.className = 'submod-overflow-badge';
22263        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
22264        badge.textContent = '+' + (chips.length - MAX) + ' more';
22265        cell.appendChild(badge);
22266        cell.style.maxHeight = 'none';
22267      });
22268    })();
22269  </script>
22270  <script nonce="{{ csp_nonce }}">
22271  (function(){
22272    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'}];
22273    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);});}
22274    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22275    function init(){
22276      var btn=document.getElementById('settings-btn');if(!btn)return;
22277      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22278      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>';
22279      document.body.appendChild(m);
22280      var g=document.getElementById('scheme-grid');
22281      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);});
22282      var cl=document.getElementById('settings-close');
22283      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);
22284      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');});
22285      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22286      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22287    }
22288    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22289  }());
22290  </script>
22291  <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>
22292</body>
22293</html>
22294"##,
22295    ext = "html"
22296)]
22297struct CompareSelectTemplate {
22298    version: &'static str,
22299    entries: Vec<HistoryEntryRow>,
22300    total_scans: usize,
22301    watched_dirs: Vec<String>,
22302    csp_nonce: String,
22303    server_mode: bool,
22304}
22305
22306// ── CompareTemplate ────────────────────────────────────────────────────────────
22307
22308#[derive(Template)]
22309#[template(
22310    source = r##"
22311<!doctype html>
22312<html lang="en">
22313<head>
22314  <meta charset="utf-8">
22315  <meta name="viewport" content="width=device-width, initial-scale=1">
22316  <title>OxideSLOC | Scan Delta</title>
22317  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22318  <style nonce="{{ csp_nonce }}">
22319    :root {
22320      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
22321      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
22322      --nav:#283790; --nav-2:#013e6b;
22323      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
22324      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
22325      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
22326    }
22327    body.dark-theme {
22328      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
22329      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
22330    }
22331    *{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;}
22332    .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);}
22333    .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;}
22334    .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));}
22335    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22336    .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;}
22337    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
22338    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22339    @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; } }
22340    .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;}
22341    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22342    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22343    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22344    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22345    .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;}
22346    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22347    .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);}
22348    .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;}
22349    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22350    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22351    .settings-modal-body{padding:14px 16px 16px;}
22352    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22353    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22354    .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;}
22355    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22356    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22357    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22358    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22359    .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;}
22360    .tz-select:focus{border-color:var(--oxide);}
22361    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
22362    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
22363    .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;}
22364    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
22365    .hero-body{display:block;}
22366    .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;}
22367    .btn-back:hover{background:var(--line);}
22368    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
22369    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
22370    .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;}
22371    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
22372    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;}
22373    .muted{color:var(--muted);font-size:14px;}
22374    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
22375    .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;}
22376    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
22377    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
22378    .vpill-arrow{font-size:20px;color:var(--muted);}
22379    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
22380    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
22381    .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;}
22382    .delta-card.delta-card-wide{padding:22px 24px;}
22383    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
22384    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
22385    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
22386    .delta-card-from{font-size:15px;color:var(--muted);}
22387    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
22388    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
22389    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
22390    .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%;}
22391    .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;}
22392    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
22393    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
22394    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
22395    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
22396    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
22397    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
22398    .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;}
22399    .meta-card-commit:hover{color:var(--oxide);}
22400    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
22401    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
22402    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
22403    .meta-value{color:var(--text);font-size:13px;}
22404    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
22405    .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;}
22406    .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);}
22407    .delta-card:hover .dc-tip{display:block;}
22408    .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;}
22409    .export-btn:hover{background:var(--line);}
22410    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
22411    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
22412    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
22413    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
22414    .delta-card-change.zero{color:var(--muted);background:transparent;}
22415    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
22416    .delta-card-pct.pos{color:var(--pos);}
22417    .delta-card-pct.neg{color:var(--neg);}
22418    .delta-card-pct.zero{color:var(--muted);}
22419    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
22420    .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;}
22421    .insight-card.insight-flag{border-color:var(--oxide);}
22422    .insight-card:hover .dc-tip{display:block;}
22423    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
22424    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
22425    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
22426    .insight-label.flag{color:var(--oxide);}
22427    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
22428    .insight-val.pos{color:var(--pos);}
22429    .insight-val.neg{color:var(--neg);}
22430    .insight-val.high{color:#c0392a;}
22431    .insight-val.med{color:#926000;}
22432    .insight-val.low{color:var(--pos);}
22433    body.dark-theme .insight-val.high{color:#ff6b6b;}
22434    body.dark-theme .insight-val.med{color:#f0c060;}
22435    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
22436    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
22437    .fc-row{display:flex;align-items:center;gap:8px;}
22438    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
22439    .fc-label{color:var(--muted);}
22440    .fc-modified .fc-count{color:#926000;}
22441    .fc-added .fc-count{color:var(--pos);}
22442    .fc-removed .fc-count{color:var(--neg);}
22443    .fc-unchanged .fc-count{color:var(--muted);}
22444    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
22445    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
22446    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
22447    .chip.modified{background:#fff2d8;color:#926000;}
22448    .chip.added{background:#e8f5ed;color:#1a8f47;}
22449    .chip.removed{background:#fdeaea;color:#b33b3b;}
22450    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
22451    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
22452    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
22453    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
22454    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
22455    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
22456    .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;}
22457    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
22458    .tab-btn:hover:not(.active){background:var(--line);}
22459    .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;}
22460    .btn-reset:hover{background:var(--line);}
22461    .table-wrap{width:100%;overflow-x:auto;}
22462    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
22463    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;}
22464    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
22465    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
22466    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
22467    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
22468    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
22469    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
22470    tr:last-child td{border-bottom:none;}
22471    tr.row-added td{background:rgba(26,143,71,0.06);}
22472    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
22473    tr.row-modified td{background:rgba(146,96,0,0.05);}
22474    tr.row-unchanged td{opacity:.6;}
22475    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
22476    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
22477    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
22478    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
22479    .status-badge.modified{background:#fff2d8;color:#926000;}
22480    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
22481    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
22482    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
22483    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
22484    .delta-val{font-weight:700;}
22485    .delta-val.pos{color:var(--pos);}
22486    .delta-val.neg{color:var(--neg);}
22487    .delta-val.zero{color:var(--muted);}
22488    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
22489    .from-to strong{color:var(--text);}
22490    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22491    .site-footer a{color:var(--muted);}
22492    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
22493    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
22494    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22495    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22496    .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;}
22497    .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;}
22498    .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;}
22499    @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));}}
22500    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
22501    .path-link:hover{color:var(--oxide-2);}
22502    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
22503    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
22504    a.vpill-id:hover{color:var(--oxide);}
22505    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
22506    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
22507    .pagination-info{font-size:13px;color:var(--muted);}
22508    .pagination-btns{display:flex;gap:6px;}
22509    .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;}
22510    .pg-btn:hover:not(:disabled){background:var(--line);}
22511    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22512    .pg-btn:disabled{opacity:.35;cursor:default;}
22513    .per-page-label{font-size:13px;color:var(--muted);}
22514    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;}
22515    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22516    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
22517    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
22518    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
22519    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
22520    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
22521    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
22522    .tab-btn.tab-unchanged{color:var(--muted);}
22523    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
22524    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
22525    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
22526    .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;}
22527    .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;}
22528    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
22529    .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;}
22530    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
22531    .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;}
22532    .submod-scope-btn:hover{background:var(--line);}
22533    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
22534    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
22535    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
22536    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
22537    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
22538    body.dark-theme .ic-card{background:var(--surface-2);}
22539    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
22540    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
22541    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
22542    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
22543    #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;}
22544  </style>
22545</head>
22546<body>
22547  <div class="background-watermarks" aria-hidden="true">
22548    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22549    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22550    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22551    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22552    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22553    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22554  </div>
22555  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22556  <div class="top-nav">
22557    <div class="top-nav-inner">
22558      <a class="brand" href="/">
22559        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22560        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
22561      </a>
22562      <div class="nav-right">
22563        <a class="nav-pill" href="/">Home</a>
22564        <div class="nav-dropdown">
22565          <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>
22566          <div class="nav-dropdown-menu">
22567            <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>
22568          </div>
22569        </div>
22570        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22571        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22572        <div class="nav-dropdown">
22573          <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>
22574          <div class="nav-dropdown-menu">
22575            <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>
22576          </div>
22577        </div>
22578        <div class="server-status-wrap" id="server-status-wrap">
22579          <div class="nav-pill server-online-pill" id="server-status-pill">
22580            <span class="status-dot" id="status-dot"></span>
22581            <span id="server-status-label">Server</span>
22582            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22583          </div>
22584          <div class="server-status-tip">
22585            OxideSLOC is running — accessible on your network.
22586            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22587          </div>
22588        </div>
22589        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22590          <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>
22591        </button>
22592        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22593          <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>
22594          <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>
22595        </button>
22596      </div>
22597    </div>
22598  </div>
22599
22600  <div class="page">
22601    <section class="hero">
22602      <div class="hero-header">
22603        <div>
22604          <h1 class="delta-title">Scan Delta</h1>
22605          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
22606          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
22607            {% if let Some(sub) = active_submodule %}
22608            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
22609            {% else if super_scope_active %}
22610            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
22611            {% else %}
22612            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
22613            {% endif %}
22614            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
22615          </div>
22616        </div>
22617        <a class="btn-back" href="/compare-scans">
22618          <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>
22619          Compare Scans
22620        </a>
22621      </div>
22622      {% if has_any_submodule_data %}
22623      <div class="submod-scope-bar">
22624        <span class="submod-scope-label">
22625          <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>
22626          Scope:
22627        </span>
22628        <div class="submod-scope-divider"></div>
22629        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
22630           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
22631           title="All files — super-repo and all submodules combined">Full scan</a>
22632        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
22633           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
22634           title="Only files that are not part of any submodule">Super-repo only</a>
22635        {% for sub in submodule_options %}
22636        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
22637           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
22638           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
22639        {% endfor %}
22640      </div>
22641      {% endif %}
22642      <div class="hero-body">
22643      <div class="meta-strip">
22644        <div class="delta-card delta-card-meta">
22645          <div class="meta-card-header">
22646            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
22647            <div class="meta-card-project-col">
22648              <div class="meta-card-project">{{ project_name }}</div>
22649              {% if has_any_submodule_data %}
22650              {% if let Some(sub) = active_submodule %}
22651              <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>
22652              {% else if super_scope_active %}
22653              <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>
22654              {% else %}
22655              <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>
22656              {% endif %}
22657              {% endif %}
22658            </div>
22659          </div>
22660          {% if !baseline_git_commit.is_empty() %}
22661          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
22662          {% else %}
22663          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
22664          {% endif %}
22665          <div class="meta-card-rows">
22666            <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>
22667            <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>
22668            <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>
22669            <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>
22670            {% if let Some(tags) = baseline_git_tags %}
22671            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22672            {% endif %}
22673          </div>
22674        </div>
22675        <div class="delta-card delta-card-meta">
22676          <div class="meta-card-header">
22677            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
22678            <div class="meta-card-project-col">
22679              <div class="meta-card-project">{{ project_name }}</div>
22680              {% if has_any_submodule_data %}
22681              {% if let Some(sub) = active_submodule %}
22682              <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>
22683              {% else if super_scope_active %}
22684              <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>
22685              {% else %}
22686              <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>
22687              {% endif %}
22688              {% endif %}
22689            </div>
22690          </div>
22691          {% if !current_git_commit.is_empty() %}
22692          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
22693          {% else %}
22694          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
22695          {% endif %}
22696          <div class="meta-card-rows">
22697            <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>
22698            <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>
22699            <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>
22700            <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>
22701            {% if let Some(tags) = current_git_tags %}
22702            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
22703            {% endif %}
22704          </div>
22705        </div>
22706      </div>
22707      <div class="delta-strip">
22708        <div class="delta-card">
22709          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
22710          <div class="delta-card-label">Code lines</div>
22711          <div class="delta-card-from">Before: {{ baseline_code }}</div>
22712          <div class="delta-card-to">{{ current_code }}</div>
22713          {% 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>
22714          {% 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>
22715          {% else %}<div class="delta-card-pct zero">±0%</div>
22716          {% endif %}
22717        </div>
22718        <div class="delta-card">
22719          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
22720          <div class="delta-card-label">Files analyzed</div>
22721          <div class="delta-card-from">Before: {{ baseline_files }}</div>
22722          <div class="delta-card-to">{{ current_files }}</div>
22723          {% 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>
22724          {% 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>
22725          {% else %}<div class="delta-card-pct zero">±0%</div>
22726          {% endif %}
22727        </div>
22728        <div class="delta-card">
22729          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
22730          <div class="delta-card-label">Comment lines</div>
22731          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
22732          <div class="delta-card-to">{{ current_comments }}</div>
22733          {% 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>
22734          {% 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>
22735          {% else %}<div class="delta-card-pct zero">±0%</div>
22736          {% endif %}
22737        </div>
22738        {{ coverage_delta_card|safe }}
22739        <div class="delta-card delta-card-wide">
22740          <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>
22741          <div class="delta-card-label">File changes</div>
22742          <div class="file-changes-grid">
22743            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
22744            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
22745            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
22746            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
22747          </div>
22748        </div>
22749      </div>
22750      <div class="insights-panel">
22751        <div class="insight-card">
22752          <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>
22753          <div class="insight-label">Lines Added</div>
22754          <div class="insight-val pos">+{{ code_lines_added }}</div>
22755          <div class="insight-sub">New or grown source lines</div>
22756        </div>
22757        <div class="insight-card">
22758          <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>
22759          <div class="insight-label">Lines Removed</div>
22760          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
22761          <div class="insight-sub">Deleted or shrunk source lines</div>
22762        </div>
22763        <div class="insight-card">
22764          <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>
22765          <div class="insight-label">Churn Rate</div>
22766          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
22767          <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>
22768        </div>
22769        {% if scope_flag %}
22770        <div class="insight-card insight-flag">
22771          <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>
22772          <div class="insight-label flag">Scope Signal</div>
22773          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
22774          <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>
22775        </div>
22776        {% endif %}
22777      </div>
22778      </div>
22779    </section>
22780
22781    <section class="panel" id="inline-charts-section">
22782      <h2>Scan Delta Charts</h2>
22783      <div class="ic-grid">
22784        <div class="ic-card">
22785          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
22786          <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>
22787          <div id="ic-c1"></div>
22788        </div>
22789        <div class="ic-card" id="ic-lang-card">
22790          <div class="ic-card-h2">Language Code Delta</div>
22791          <div id="ic-c3"></div>
22792        </div>
22793        <div class="ic-card">
22794          <div class="ic-card-h2">Delta by Metric</div>
22795          <div id="ic-c2"></div>
22796        </div>
22797        <div class="ic-card">
22798          <div class="ic-card-h2">File Change Distribution</div>
22799          <div id="ic-c4"></div>
22800        </div>
22801      </div>
22802    </section>
22803
22804    <section class="panel">
22805      <h2>File-level delta</h2>
22806      <div class="filter-tabs-row">
22807        <div class="filter-tabs">
22808          <button class="tab-btn tab-all active" data-filter="all">All</button>
22809          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
22810          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
22811          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
22812          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
22813        </div>
22814        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
22815          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
22816          <div class="export-group">
22817            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
22818            <button type="button" class="export-btn" id="delta-csv-btn">
22819              <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>
22820              CSV
22821            </button>
22822            <button type="button" class="export-btn" id="delta-xls-btn">
22823              <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>
22824              Excel
22825            </button>
22826            <button type="button" class="export-btn" id="delta-charts-btn">
22827              <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>
22828              Charts
22829            </button>
22830          </div>
22831        </div>
22832      </div>
22833
22834      <div class="table-wrap">
22835      <table id="delta-table">
22836        <colgroup>
22837          <col>
22838          <col>
22839          <col>
22840          <col>
22841          <col>
22842          <col>
22843          <col>
22844        </colgroup>
22845        <thead>
22846          <tr id="delta-thead">
22847            <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>
22848            <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>
22849            <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>
22850            <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>
22851            <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>
22852            <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>
22853            <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>
22854          </tr>
22855        </thead>
22856        <tbody id="delta-tbody">
22857          {% for row in file_rows %}
22858          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
22859              data-path="{{ row.relative_path }}"
22860              data-language="{{ row.language }}"
22861              data-baseline-code="{{ row.baseline_code }}"
22862              data-current-code="{{ row.current_code }}"
22863              data-code-delta="{{ row.code_delta_str }}"
22864              data-comment-delta="{{ row.comment_delta_str }}"
22865              data-total-delta="{{ row.total_delta_str }}"
22866              data-orig-idx="">
22867            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
22868            <td class="hide-sm">{{ row.language }}</td>
22869            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
22870            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
22871            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
22872            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
22873            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
22874          </tr>
22875          {% endfor %}
22876        </tbody>
22877      </table>
22878      </div>
22879      <div class="pagination">
22880        <span class="pagination-info" id="pg-info"></span>
22881        <div class="pagination-btns" id="pg-btns"></div>
22882        <div class="flex-row">
22883          <span class="per-page-label">Show</span>
22884          <select class="per-page" id="per-page-sel">
22885            <option value="10">10 per page</option>
22886            <option value="25" selected>25 per page</option>
22887            <option value="50">50 per page</option>
22888            <option value="100">100 per page</option>
22889          </select>
22890          <span class="per-page-label" id="pg-range-label"></span>
22891        </div>
22892      </div>
22893    </section>
22894  </div>
22895
22896  <div id="ic-tt"></div>
22897
22898  <footer class="site-footer">
22899    local code analysis - metrics, history and reports
22900    &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>
22901    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22902    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22903    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22904    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22905  </footer>
22906
22907  <script nonce="{{ csp_nonce }}">
22908    (function () {
22909      var storageKey = 'oxide-sloc-theme';
22910      var body = document.body;
22911      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22912      var toggle = document.getElementById('theme-toggle');
22913      if (toggle) toggle.addEventListener('click', function () {
22914        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22915        body.classList.toggle('dark-theme', next === 'dark');
22916        try { localStorage.setItem(storageKey, next); } catch(e) {}
22917      });
22918
22919      (function randomizeWatermarks() {
22920        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22921        if (!wms.length) return;
22922        var placed = [];
22923        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;}
22924        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];}
22925        var half=Math.floor(wms.length/2);
22926        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;});
22927      })();
22928
22929      (function spawnCodeParticles() {
22930        var container = document.getElementById('code-particles');
22931        if (!container) return;
22932        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'];
22933        for (var i = 0; i < 38; i++) {
22934          (function(idx) {
22935            var el = document.createElement('span');
22936            el.className = 'code-particle';
22937            el.textContent = snippets[idx % snippets.length];
22938            var left = Math.random() * 94 + 2;
22939            var top = Math.random() * 88 + 6;
22940            var dur = (Math.random() * 10 + 9).toFixed(1);
22941            var delay = (Math.random() * 18).toFixed(1);
22942            var rot = (Math.random() * 26 - 13).toFixed(1);
22943            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22944            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';
22945            container.appendChild(el);
22946          })(i);
22947        }
22948      })();
22949    })();
22950
22951    var activeStatusFilter = 'all';
22952    var deltaPerPage = 25, deltaCurrPage = 1;
22953
22954    function openFolder(path) {
22955      fetch('/open-path?path=' + encodeURIComponent(path))
22956        .then(function (r) { return r.json(); })
22957        .then(function (d) {
22958          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
22959        })
22960        .catch(function () {});
22961    }
22962
22963    function getDeltaFilteredRows() {
22964      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
22965        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
22966      });
22967    }
22968
22969    function renderDeltaPage() {
22970      var filtered = getDeltaFilteredRows();
22971      var total = filtered.length;
22972      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
22973      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
22974      var start = (deltaCurrPage - 1) * deltaPerPage;
22975      var end = Math.min(start + deltaPerPage, total);
22976      var shownSet = {};
22977      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
22978      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
22979        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
22980      });
22981      var rl = document.getElementById('pg-range-label');
22982      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
22983      var info = document.getElementById('pg-info');
22984      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
22985      var btns = document.getElementById('pg-btns');
22986      if (!btns) return;
22987      btns.innerHTML = '';
22988      if (totalPages <= 1) return;
22989      function makeBtn(lbl, pg, active, disabled) {
22990        var b = document.createElement('button');
22991        b.className = 'pg-btn' + (active ? ' active' : '');
22992        b.textContent = lbl; b.disabled = disabled;
22993        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
22994        return b;
22995      }
22996      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
22997      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
22998      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
22999      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
23000    }
23001
23002    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
23003
23004    function filterRows(status, btn) {
23005      activeStatusFilter = status;
23006      deltaCurrPage = 1;
23007      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
23008        b.classList.remove('active');
23009      });
23010      if (btn) btn.classList.add('active');
23011      renderDeltaPage();
23012    }
23013
23014    // ── Sorting ──────────────────────────────────────────────────────────────
23015    var sortCol = null, sortOrder = 'asc';
23016    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
23017    (function() {
23018      var tbody = document.getElementById('delta-tbody');
23019      if (!tbody) return;
23020      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23021      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
23022    })();
23023
23024    function parseDeltaNum(str) {
23025      if (!str || str === '—') return 0;
23026      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
23027    }
23028
23029    sortHeaders.forEach(function(th) {
23030      th.addEventListener('click', function(e) {
23031        if (e.target.classList.contains('col-resize-handle')) return;
23032        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
23033        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
23034        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23035        th.classList.add('sort-' + sortOrder);
23036        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
23037        var tbody = document.getElementById('delta-tbody');
23038        if (!tbody) return;
23039        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23040        rows.sort(function(a, b) {
23041          var va, vb;
23042          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
23043          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
23044          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
23045          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
23046          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23047          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23048          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
23049          else { va = ''; vb = ''; }
23050          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
23051          return va < vb ? 1 : va > vb ? -1 : 0;
23052        });
23053        rows.forEach(function(r) { tbody.appendChild(r); });
23054        deltaCurrPage = 1;
23055        renderDeltaPage();
23056        var activeBtn = document.querySelector('.tab-btn.active');
23057        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23058        if (activeBtn) activeBtn.classList.add('active');
23059      });
23060    });
23061
23062    // ── Column resize ─────────────────────────────────────────────────────────
23063    (function() {
23064      var table = document.getElementById('delta-table');
23065      if (!table) return;
23066      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
23067      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
23068      ths.forEach(function(th, i) {
23069        var handle = th.querySelector('.col-resize-handle');
23070        if (!handle || !cols[i]) return;
23071        var startX, startW;
23072        handle.addEventListener('mousedown', function(e) {
23073          e.stopPropagation(); e.preventDefault();
23074          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
23075          handle.classList.add('dragging');
23076          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
23077          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
23078          document.addEventListener('mousemove', onMove);
23079          document.addEventListener('mouseup', onUp);
23080        });
23081      });
23082    })();
23083
23084    // ── Reset ─────────────────────────────────────────────────────────────────
23085    window.resetDeltaTable = function() {
23086      sortCol = null; sortOrder = 'asc';
23087      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
23088      var tbody = document.getElementById('delta-tbody');
23089      if (tbody) {
23090        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
23091        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
23092        rows.forEach(function(r) { tbody.appendChild(r); });
23093      }
23094      var table = document.getElementById('delta-table');
23095      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
23096      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
23097      activeStatusFilter = 'all';
23098      deltaCurrPage = 1;
23099      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
23100      var allBtn = document.querySelector('.tab-btn');
23101      if (allBtn) allBtn.classList.add('active');
23102      renderDeltaPage();
23103    };
23104
23105    renderDeltaPage();
23106
23107    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
23108    (function() {
23109      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
23110        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
23111      });
23112      var resetBtn = document.getElementById('delta-reset-btn');
23113      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
23114      var csvBtn = document.getElementById('delta-csv-btn');
23115      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
23116      var xlsBtn = document.getElementById('delta-xls-btn');
23117      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
23118      var chartsBtn = document.getElementById('delta-charts-btn');
23119      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
23120      var ppSel = document.getElementById('per-page-sel');
23121      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
23122      var pathLink = document.getElementById('project-path-link');
23123      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
23124    })();
23125
23126    // ── Export helpers ────────────────────────────────────────────────────────
23127    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
23128    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
23129    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);}
23130    function slocMakeXlsx(fname,sd,dr){
23131      var enc=new TextEncoder();
23132      // CRC-32 table
23133      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;}
23134      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;}
23135      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
23136      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
23137      // Shared string table
23138      var ss=[],si={};
23139      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
23140      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23141      // Worksheet builder — each WS() call gets its own row counter R
23142      function WS(){
23143        var R=0,buf=[];
23144        function cl(c){return String.fromCharCode(65+c);}
23145        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
23146          '<v>'+S(v)+'</v></c>';}
23147        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
23148          (st?' s="'+st+'"':'')+'>'+
23149          '<v>'+(+v)+'</v></c>';}
23150        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
23151        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23152          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
23153          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
23154          '<sheetFormatPr defaultRowHeight="15"/>'+
23155          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
23156        return{sc:sc,nc:nc,row:row,xml:xml};
23157      }
23158      // Language breakdown
23159      var lm={};
23160      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;});
23161      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
23162      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
23163      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
23164      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
23165      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23166      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23167      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):'';}
23168      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
23169      // Summary sheet
23170      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
23171      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
23172      r1(s1(0,proj,2));
23173      r1(s1(0,sd.bts+' → '+sd.cts,2));
23174      r1('');
23175      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
23176      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))));
23177      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))));
23178      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))));
23179      r1('');
23180      r1(s1(0,'FILE CHANGES',8));
23181      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
23182      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
23183      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
23184      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
23185      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
23186      if(langs.length){
23187        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
23188        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
23189        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)));});
23190      }
23191      r1('');r1(s1(0,'SCAN METADATA',8));
23192      r1(s1(1,_blabel)+s1(2,_clabel));
23193      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
23194      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
23195      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"/>');
23196      // File Delta sheet
23197      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
23198      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));
23199      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)));});
23200      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
23201      // Shared strings XML
23202      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
23203        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
23204        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
23205      // XLSX file map
23206      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
23207      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>',
23208        '_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>',
23209        '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>',
23210        '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>',
23211        '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>',
23212        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
23213      // ZIP packer — STORED (no compression), compatible with all XLSX readers
23214      var zparts=[],zcds=[],zoff=0,znf=0;
23215      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
23216       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
23217      ].forEach(function(name){
23218        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
23219        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]);
23220        var entry=new Uint8Array(lha.length+nb.length+sz);
23221        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
23222        zparts.push(entry);
23223        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));
23224        var cde=new Uint8Array(cda.length+nb.length);
23225        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
23226        zcds.push(cde);zoff+=entry.length;znf++;
23227      });
23228      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
23229      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]);
23230      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
23231      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
23232      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
23233      zout.set(new Uint8Array(ea),zpos);
23234      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
23235      var xurl=URL.createObjectURL(xblob);
23236      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
23237      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
23238      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
23239    }
23240    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;');}
23241    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
23242    function getExportFilename(ext){return _exportBase+'.'+ext;}
23243
23244    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 }}'};
23245    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;}
23246    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
23247    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
23248    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
23249    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
23250    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):'';}
23251    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
23252    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)]];}
23253    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
23254    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;}
23255    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
23256    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
23257
23258    // ── Chart HTML report ─────────────────────────────────────────────────────
23259    function slocChartReport(fname, sd, dr) {
23260      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
23261      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23262      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23263      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();}
23264      function px(n){return Math.round(n);}
23265      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
23266      // Language map
23267      var lm={};
23268      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;});
23269      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23270
23271      // Builds onmouse* attrs for interactive tooltip on each SVG element
23272      function barTT(label,val){
23273        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
23274      }
23275
23276      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
23277      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'}];
23278      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23279      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
23280      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
23281      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23282      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"/>';}
23283      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23284      c1mets.forEach(function(m,i){
23285        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23286        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23287        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>';
23288        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))+'/>';
23289        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>';
23290        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))+'/>';
23291        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>';
23292        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>';
23293        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>';
23294      });
23295      c1+='</svg>';
23296
23297      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
23298      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'}];
23299      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23300      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
23301      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
23302      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23303      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23304      mets.forEach(function(m,i){
23305        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
23306        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
23307        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
23308        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>';
23309        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
23310        if(bw>=52){
23311          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>';
23312        }else{
23313          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
23314          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>';
23315        }
23316      });
23317      c2+='</svg>';
23318
23319      // ── Chart 3: Language Code Delta ─────────────────────────────────────
23320      var c3='';
23321      if(langs.length){
23322        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23323        var C3W=550,c3LW=124,c3FW=52;
23324        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
23325        var L3rH=30,C3H=langs.length*L3rH+20;
23326        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23327        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23328        langs.forEach(function(l,i){
23329          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
23330          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
23331          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
23332          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23333          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':''))+'/>';
23334          if(bw>=48){
23335            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>';
23336          }else{
23337            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
23338            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>';
23339          }
23340          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>';
23341        });
23342        c3+='</svg>';
23343      }
23344
23345      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
23346      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;});
23347      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23348      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
23349      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23350      var ang=-Math.PI/2;
23351      segs.forEach(function(s){
23352        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23353        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
23354        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
23355        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
23356        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
23357        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)+'%')+'/>';
23358        ang+=sw;
23359      });
23360      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>';
23361      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23362      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>';});
23363      c4+='</svg>';
23364
23365      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
23366      var ttJs='var tt=document.getElementById("ox-tt");'+
23367        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
23368        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
23369        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
23370        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
23371        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
23372        'function oxHT(){tt.style.display="none";}';
23373
23374      // body max-width keeps charts from inflating beyond design dimensions on
23375      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
23376      // each chart's height blows up proportionally, breaking the one-page layout.
23377      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;}'+
23378        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
23379        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
23380        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
23381        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
23382        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
23383        'svg{display:block;}'+
23384        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
23385        '#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;}'+
23386        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
23387      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
23388        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
23389        '<div id="ox-tt"><\/div>'+
23390        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
23391        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
23392        '<div class="two-col">'+
23393        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
23394        '<div class="leg">'+
23395        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
23396        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
23397        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
23398        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
23399        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
23400        '<\/div>'+
23401        '<div class="two-col">'+
23402        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
23403        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
23404        '<\/div>'+
23405        '<script>'+ttJs+'<\/script>'+
23406        '<\/body><\/html>';
23407      slocDownload(html, fname, 'text/html;charset=utf-8;');
23408    }
23409    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
23410    // ── Inline delta charts ────────────────────────────────────────────────────
23411    var _icTT=document.getElementById('ic-tt');
23412    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
23413    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';};
23414    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
23415    (function(){
23416      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
23417      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23418      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();}
23419      function px(n){return Math.round(n);}
23420      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
23421      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
23422      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);});}
23423      var dr=getDeltaExportRows(),sd=_sd,lm={};
23424      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;});
23425      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
23426      // Chart 1: Baseline vs Current grouped bars
23427      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'}];
23428      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
23429      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;
23430      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23431      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"/>';}
23432      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
23433      c1mets.forEach(function(m,i){
23434        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
23435        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
23436        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>';
23437        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"/>';
23438        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>';
23439        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"/>';
23440        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>';
23441        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>';
23442        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>';
23443      });
23444      c1+='</svg>';
23445      // Chart 2: Delta by Metric
23446      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'}];
23447      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
23448      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;
23449      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23450      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23451      mets.forEach(function(m,i){
23452        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);
23453        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>';
23454        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
23455        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>';}
23456        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>';}
23457      });
23458      c2+='</svg>';
23459      // Chart 3: Language Code Delta
23460      var c3='';
23461      if(langs.length){
23462        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
23463        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;
23464        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
23465        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
23466        langs.forEach(function(l,i){
23467          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);
23468          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
23469          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"/>';
23470          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>';}
23471          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>';}
23472          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>';
23473        });
23474        c3+='</svg>';
23475      }
23476      // Chart 4: File Change Donut
23477      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;});
23478      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
23479      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;
23480      if(segs.length===1){
23481        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
23482        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
23483        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
23484      } else {
23485        segs.forEach(function(s){
23486          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23487          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);
23488          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);
23489          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"/>';
23490          ang+=sw;
23491        });
23492      }
23493      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>';
23494      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
23495      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>';});
23496      c4+='</svg>';
23497      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
23498      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
23499      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);}
23500      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
23501      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
23502      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='  /'+el.textContent.replace(/\s+/g,'');});
23503    })();
23504  </script>
23505  <script nonce="{{ csp_nonce }}">
23506  (function(){
23507    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'}];
23508    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);});}
23509    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23510    function init(){
23511      var btn=document.getElementById('settings-btn');if(!btn)return;
23512      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23513      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>';
23514      document.body.appendChild(m);
23515      var g=document.getElementById('scheme-grid');
23516      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);});
23517      var cl=document.getElementById('settings-close');
23518      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);
23519      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');});
23520      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23521      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23522    }
23523    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23524  }());
23525  </script>
23526  <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>
23527</body>
23528</html>
23529"##,
23530    ext = "html"
23531)]
23532// Template structs need many bool fields to pass Askama rendering flags.
23533#[allow(clippy::struct_excessive_bools)]
23534struct CompareTemplate {
23535    version: &'static str,
23536    project_label: String,
23537    baseline_git_commit: String,
23538    current_git_commit: String,
23539    baseline_run_id: String,
23540    current_run_id: String,
23541    baseline_run_id_short: String,
23542    current_run_id_short: String,
23543    baseline_timestamp: String,
23544    baseline_timestamp_utc_ms: i64,
23545    current_timestamp: String,
23546    current_timestamp_utc_ms: i64,
23547    project_path: String,
23548    baseline_code: u64,
23549    current_code: u64,
23550    code_lines_delta_str: String,
23551    code_lines_delta_class: String,
23552    baseline_files: u64,
23553    current_files: u64,
23554    files_analyzed_delta_str: String,
23555    files_analyzed_delta_class: String,
23556    baseline_comments: u64,
23557    current_comments: u64,
23558    comment_lines_delta_str: String,
23559    comment_lines_delta_class: String,
23560    code_lines_pct_str: String,
23561    files_analyzed_pct_str: String,
23562    comment_lines_pct_str: String,
23563    code_lines_added: i64,
23564    code_lines_removed: i64,
23565    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
23566    new_scope: bool,
23567    churn_rate_str: String,
23568    churn_rate_class: String,
23569    scope_flag: bool,
23570    files_added: usize,
23571    files_removed: usize,
23572    files_modified: usize,
23573    files_unchanged: usize,
23574    file_rows: Vec<CompareFileDeltaRow>,
23575    baseline_git_author: Option<String>,
23576    current_git_author: Option<String>,
23577    baseline_git_branch: String,
23578    current_git_branch: String,
23579    baseline_git_tags: Option<String>,
23580    current_git_tags: Option<String>,
23581    baseline_git_commit_date: Option<String>,
23582    current_git_commit_date: Option<String>,
23583    project_name: String,
23584    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
23585    submodule_options: Vec<String>,
23586    /// True when either run has submodule data — controls whether the scope bar is shown.
23587    has_any_submodule_data: bool,
23588    /// The submodule currently being compared, if the `sub` query param was provided.
23589    active_submodule: Option<String>,
23590    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
23591    super_scope_active: bool,
23592    csp_nonce: String,
23593    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
23594    coverage_delta_card: String,
23595}
23596
23597// ── LoginTemplate ──────────────────────────────────────────────────────────────
23598
23599#[derive(Template)]
23600#[template(
23601    source = r##"
23602<!doctype html>
23603<html lang="en">
23604<head>
23605  <meta charset="utf-8">
23606  <meta name="viewport" content="width=device-width, initial-scale=1">
23607  <title>OxideSLOC | Sign In</title>
23608  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23609  <style nonce="{{ csp_nonce }}">
23610    :root {
23611      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
23612      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
23613      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
23614      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
23615    }
23616    *{box-sizing:border-box;}
23617    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);}
23618    .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);}
23619    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
23620    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
23621    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
23622    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23623    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23624    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23625    .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;}
23626    @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));}}
23627    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
23628    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
23629    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
23630    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
23631    .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;}
23632    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
23633    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;}
23634    input[type=password]:focus{border-color:var(--oxide);}
23635    .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;}
23636    .btn:hover{opacity:.88;}
23637    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
23638    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
23639  </style>
23640</head>
23641<body>
23642  <div class="background-watermarks" aria-hidden="true">
23643    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23644    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23645    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23646    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23647    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23648    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23649    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23650  </div>
23651  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23652<nav class="top-nav">
23653  <a class="brand" href="/">
23654    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
23655    <span class="brand-title">OxideSLOC</span>
23656  </a>
23657</nav>
23658<main class="page">
23659  <div class="card">
23660    <h1>Sign In</h1>
23661    <p class="subtitle">Enter the API key printed when the server started.</p>
23662    {% if has_error %}
23663    <div class="error">Incorrect API key — please try again.</div>
23664    {% endif %}
23665    <form method="POST" action="/auth/login">
23666      <input type="hidden" name="next" value="{{ next_url|e }}">
23667      <label for="key">API Key</label>
23668      <input id="key" type="password" name="key" autocomplete="current-password"
23669             placeholder="Paste your API key here" autofocus>
23670      <button type="submit" class="btn">Sign In</button>
23671    </form>
23672    <p class="hint">
23673      The API key was printed in the terminal when the server started.<br>
23674      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
23675      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
23676    </p>
23677  </div>
23678</main>
23679<script nonce="{{ csp_nonce }}">
23680(function() {
23681  (function randomizeWatermarks() {
23682    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23683    if (!wms.length) return;
23684    var placed = [];
23685    function tooClose(top, left) {
23686      for (var i = 0; i < placed.length; i++) {
23687        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
23688        if (dt < 16 && dl < 12) return true;
23689      }
23690      return false;
23691    }
23692    function pick(leftBand) {
23693      for (var attempt = 0; attempt < 50; attempt++) {
23694        var top = Math.random() * 88 + 2;
23695        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23696        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
23697      }
23698      var top = Math.random() * 88 + 2;
23699      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
23700      placed.push([top, left]); return [top, left];
23701    }
23702    var half = Math.floor(wms.length / 2);
23703    wms.forEach(function (img, i) {
23704      var pos = pick(i < half);
23705      var size = Math.floor(Math.random() * 100 + 120);
23706      var rot = (Math.random() * 360).toFixed(1);
23707      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
23708      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;
23709    });
23710  })();
23711  (function spawnCodeParticles() {
23712    var container = document.getElementById('code-particles');
23713    if (!container) return;
23714    var snippets = [
23715      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
23716      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
23717      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
23718      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
23719      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
23720    ];
23721    var count = 38;
23722    for (var i = 0; i < count; i++) {
23723      (function(idx) {
23724        var el = document.createElement('span');
23725        el.className = 'code-particle';
23726        el.textContent = snippets[idx % snippets.length];
23727        var left = Math.random() * 94 + 2;
23728        var top = Math.random() * 88 + 6;
23729        var dur = (Math.random() * 10 + 9).toFixed(1);
23730        var delay = (Math.random() * 18).toFixed(1);
23731        var rot = (Math.random() * 26 - 13).toFixed(1);
23732        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23733        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
23734        container.appendChild(el);
23735      })(i);
23736    }
23737  })();
23738})();
23739</script>
23740</body>
23741</html>
23742"##,
23743    ext = "html"
23744)]
23745pub(crate) struct LoginTemplate {
23746    pub(crate) csp_nonce: String,
23747    pub(crate) has_error: bool,
23748    pub(crate) next_url: String,
23749    pub(crate) lockout_threshold: u32,
23750}
23751
23752// ── REST API reference page ────────────────────────────────────────────────────
23753
23754#[derive(Template)]
23755#[template(
23756    source = r##"
23757<!doctype html>
23758<html lang="en">
23759<head>
23760  <meta charset="utf-8">
23761  <meta name="viewport" content="width=device-width, initial-scale=1">
23762  <title>OxideSLOC — REST API Reference</title>
23763  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23764  <style nonce="{{ csp_nonce }}">
23765    :root {
23766      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23767      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23768      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23769      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23770      --success:#16a34a;
23771    }
23772    body.dark-theme {
23773      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
23774      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
23775    }
23776    *{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;}
23777    .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);}
23778    .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;}
23779    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
23780    .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));}
23781    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
23782    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
23783    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
23784    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
23785    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23786    @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; } }
23787    .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;}
23788    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
23789    .nav-pill.active{background:rgba(255,255,255,0.22);}
23790    .nav-dropdown{position:relative;display:inline-flex;}
23791    .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;}
23792    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
23793    .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;}
23794    .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;}
23795    .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);}
23796    .nav-dropdown-menu a:last-child{border-bottom:none;}
23797    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
23798    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
23799    .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;}
23800    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23801    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23802    .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;}
23803    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23804    .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);}
23805    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
23806    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
23807    .settings-modal-body{padding:14px 16px 16px;}
23808    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23809    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23810    .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;}
23811    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23812    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23813    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23814    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23815    .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;}
23816    .tz-select:focus{border-color:var(--oxide);}
23817    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
23818    .page-header{margin-bottom:28px;}
23819    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
23820    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
23821    .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;}
23822    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
23823    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
23824    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
23825    .callout strong{font-weight:800;}
23826    .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;}
23827    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
23828    .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;}
23829    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
23830    .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;}
23831    body.dark-theme .base-url-value{color:var(--accent);}
23832    .section{margin-bottom:36px;}
23833    .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);}
23834    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
23835    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
23836    .ep-header:hover{background:var(--surface-2);}
23837    .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;}
23838    .method.get{background:#dcfce7;color:#166534;}
23839    .method.post{background:#dbeafe;color:#1e40af;}
23840    .method.delete{background:#fee2e2;color:#991b1b;}
23841    body.dark-theme .method.get{background:#14532d;color:#86efac;}
23842    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
23843    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
23844    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
23845    .ep-path .param{color:var(--oxide-2);}
23846    body.dark-theme .ep-path .param{color:var(--oxide);}
23847    .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;}
23848    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
23849    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
23850    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
23851    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
23852    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
23853    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
23854    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
23855    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
23856    .ep-card.open .chevron{transform:rotate(180deg);}
23857    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
23858    .ep-card.open .ep-body{display:block;}
23859    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
23860    .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;}
23861    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
23862    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
23863    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
23864    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
23865    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);}
23866    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
23867    table.params tr:last-child td{border-bottom:none;}
23868    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
23869    .pt-type{color:var(--muted-2);font-size:12px;}
23870    .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;}
23871    .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;}
23872    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
23873    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
23874    details.schema{margin-bottom:14px;}
23875    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;}
23876    details.schema summary:hover{color:var(--text);}
23877    .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;}
23878    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
23879    .curl-wrap{position:relative;}
23880    .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;}
23881    .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;}
23882    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
23883    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
23884    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
23885    .webhook-note a{color:var(--accent-2);text-decoration:none;}
23886    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23887    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23888    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23889    .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;}
23890    @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));}}
23891    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23892    .site-footer a{color:var(--muted);}
23893  </style>
23894</head>
23895<body>
23896  <div class="background-watermarks" aria-hidden="true">
23897    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23898    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23899    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23900    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23901    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23902    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23903    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23904  </div>
23905  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23906  <div class="top-nav">
23907    <div class="top-nav-inner">
23908      <a class="brand" href="/">
23909        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23910        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
23911      </a>
23912      <div class="nav-right">
23913        <a class="nav-pill" href="/">Home</a>
23914        <div class="nav-dropdown">
23915          <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>
23916          <div class="nav-dropdown-menu">
23917            <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>
23918          </div>
23919        </div>
23920        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23921        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23922        <div class="nav-dropdown">
23923          <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>
23924          <div class="nav-dropdown-menu">
23925            <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>
23926          </div>
23927        </div>
23928        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23929          <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>
23930        </button>
23931        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23932          <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>
23933          <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>
23934        </button>
23935      </div>
23936    </div>
23937  </div>
23938
23939  <div class="page">
23940    <div class="page-header">
23941      <h1 class="page-title">REST API Reference</h1>
23942      <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>
23943    </div>
23944
23945    {% if has_api_key %}
23946    <div class="callout key-set">
23947      <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>
23948      <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>
23949    </div>
23950    {% else %}
23951    <div class="callout no-key">
23952      <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>
23953      <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>
23954    </div>
23955    {% endif %}
23956
23957    <div class="base-url-bar">
23958      <span class="base-url-label">Base URL</span>
23959      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
23960    </div>
23961
23962    <!-- Health -->
23963    <div class="section">
23964      <h2 class="section-title">Health &amp; Status</h2>
23965      <div class="ep-card">
23966        <div class="ep-header">
23967          <span class="method get">GET</span>
23968          <span class="ep-path">/healthz</span>
23969          <span class="auth-badge public">Public</span>
23970          <span class="ep-desc">Server liveness check</span>
23971          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23972        </div>
23973        <div class="ep-body">
23974          <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>
23975          <p class="params-heading">Response</p>
23976          <div class="schema-block">200 OK
23977Content-Type: text/plain
23978
23979ok</div>
23980          <p class="curl-heading">Example</p>
23981          <div class="curl-wrap">
23982            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
23983            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
23984          </div>
23985        </div>
23986      </div>
23987    </div>
23988
23989    <!-- Badges -->
23990    <div class="section">
23991      <h2 class="section-title">Badges</h2>
23992      <div class="ep-card">
23993        <div class="ep-header">
23994          <span class="method get">GET</span>
23995          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
23996          <span class="auth-badge public">Public</span>
23997          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
23998          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23999        </div>
24000        <div class="ep-body">
24001          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
24002          <p class="params-heading">Path Parameters</p>
24003          <table class="params">
24004            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24005            <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>
24006          </table>
24007          <p class="curl-heading">Example</p>
24008          <div class="curl-wrap">
24009            <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>
24010            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
24011          </div>
24012        </div>
24013      </div>
24014    </div>
24015
24016    <!-- Metrics -->
24017    <div class="section">
24018      <h2 class="section-title">Metrics</h2>
24019
24020      <div class="ep-card">
24021        <div class="ep-header">
24022          <span class="method get">GET</span>
24023          <span class="ep-path">/api/metrics/latest</span>
24024          <span class="auth-badge protected">Protected</span>
24025          <span class="ep-desc">Latest scan metrics (JSON)</span>
24026          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24027        </div>
24028        <div class="ep-body">
24029          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
24030          <details class="schema"><summary>Response schema</summary>
24031<div class="schema-block">{
24032  "run_id":    string,        // UUID
24033  "timestamp": string,        // ISO-8601 UTC
24034  "project":   string,        // scanned root path
24035  "summary": {
24036    "files_analyzed":       number,
24037    "files_skipped":        number,
24038    "code_lines":           number,
24039    "comment_lines":        number,
24040    "blank_lines":          number,
24041    "total_physical_lines": number,
24042    "functions":            number,
24043    "classes":              number,
24044    "variables":            number,
24045    "imports":              number
24046  },
24047  "languages": [
24048    { "name": string, "files": number, "code_lines": number,
24049      "comment_lines": number, "blank_lines": number,
24050      "functions": number, "classes": number,
24051      "variables": number, "imports": number }
24052  ]
24053}</div></details>
24054          <p class="curl-heading">Example</p>
24055          <div class="curl-wrap">
24056            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24057  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
24058            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
24059          </div>
24060        </div>
24061      </div>
24062
24063      <div class="ep-card">
24064        <div class="ep-header">
24065          <span class="method get">GET</span>
24066          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
24067          <span class="auth-badge protected">Protected</span>
24068          <span class="ep-desc">Metrics for a specific run</span>
24069          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24070        </div>
24071        <div class="ep-body">
24072          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
24073          <p class="params-heading">Path Parameters</p>
24074          <table class="params">
24075            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24076            <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>
24077          </table>
24078          <p class="curl-heading">Example</p>
24079          <div class="curl-wrap">
24080            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24081  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
24082            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
24083          </div>
24084        </div>
24085      </div>
24086
24087      <div class="ep-card">
24088        <div class="ep-header">
24089          <span class="method get">GET</span>
24090          <span class="ep-path">/api/metrics/history</span>
24091          <span class="auth-badge protected">Protected</span>
24092          <span class="ep-desc">Paginated scan history</span>
24093          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24094        </div>
24095        <div class="ep-body">
24096          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
24097          <p class="params-heading">Query Parameters</p>
24098          <table class="params">
24099            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24100            <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>
24101            <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>
24102          </table>
24103          <details class="schema"><summary>Response schema</summary>
24104<div class="schema-block">[{
24105  "run_id":         string,
24106  "timestamp":      string,   // ISO-8601 UTC
24107  "commit":         string | null,
24108  "branch":         string | null,
24109  "tags":           string[],
24110  "code_lines":     number,
24111  "comment_lines":  number,
24112  "blank_lines":    number,
24113  "physical_lines": number,
24114  "files_analyzed": number,
24115  "project_label":  string,
24116  "html_url":       string | null
24117}]</div></details>
24118          <p class="curl-heading">Example</p>
24119          <div class="curl-wrap">
24120            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24121  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
24122            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
24123          </div>
24124        </div>
24125      </div>
24126
24127      <div class="ep-card">
24128        <div class="ep-header">
24129          <span class="method get">GET</span>
24130          <span class="ep-path">/api/project-history</span>
24131          <span class="auth-badge protected">Protected</span>
24132          <span class="ep-desc">Project-level scan summary</span>
24133          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24134        </div>
24135        <div class="ep-body">
24136          <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>
24137          <p class="params-heading">Query Parameters</p>
24138          <table class="params">
24139            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24140            <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>
24141          </table>
24142          <details class="schema"><summary>Response schema</summary>
24143<div class="schema-block">{
24144  "scan_count":           number,
24145  "last_scan_id":         string | null,
24146  "last_scan_timestamp":  string | null,  // ISO-8601
24147  "last_scan_code_lines": number | null,
24148  "last_git_branch":      string | null,
24149  "last_git_commit":      string | null
24150}</div></details>
24151          <p class="curl-heading">Example</p>
24152          <div class="curl-wrap">
24153            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24154  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
24155            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
24156          </div>
24157        </div>
24158      </div>
24159
24160      <div class="ep-card">
24161        <div class="ep-header">
24162          <span class="method get">GET</span>
24163          <span class="ep-path">/api/metrics/submodules</span>
24164          <span class="auth-badge protected">Protected</span>
24165          <span class="ep-desc">List known git submodules across scans</span>
24166          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24167        </div>
24168        <div class="ep-body">
24169          <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>
24170          <p class="params-heading">Query Parameters</p>
24171          <table class="params">
24172            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24173            <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>
24174          </table>
24175          <details class="schema"><summary>Response schema</summary>
24176<div class="schema-block">[{
24177  "name":          string,  // submodule name
24178  "relative_path": string   // path relative to the project root
24179}]</div></details>
24180          <p class="curl-heading">Example</p>
24181          <div class="curl-wrap">
24182            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24183  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
24184            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
24185          </div>
24186        </div>
24187      </div>
24188    </div>
24189
24190    <!-- Async Run Status -->
24191    <div class="section">
24192      <h2 class="section-title">Async Run Status</h2>
24193
24194      <div class="ep-card">
24195        <div class="ep-header">
24196          <span class="method get">GET</span>
24197          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
24198          <span class="auth-badge protected">Protected</span>
24199          <span class="ep-desc">Poll scan completion</span>
24200          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24201        </div>
24202        <div class="ep-body">
24203          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
24204          <details class="schema"><summary>Response schema</summary>
24205<div class="schema-block">// Running
24206{ "state": "running",  "elapsed_secs": number }
24207
24208// Complete
24209{ "state": "complete", "run_id": string }
24210
24211// Failed
24212{ "state": "failed",   "message": string }</div></details>
24213          <p class="curl-heading">Example</p>
24214          <div class="curl-wrap">
24215            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24216  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
24217            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
24218          </div>
24219        </div>
24220      </div>
24221
24222      <div class="ep-card">
24223        <div class="ep-header">
24224          <span class="method get">GET</span>
24225          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
24226          <span class="auth-badge protected">Protected</span>
24227          <span class="ep-desc">Poll PDF generation readiness</span>
24228          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24229        </div>
24230        <div class="ep-body">
24231          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
24232          <details class="schema"><summary>Response schema</summary>
24233<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
24234          <p class="curl-heading">Example</p>
24235          <div class="curl-wrap">
24236            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24237  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
24238            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
24239          </div>
24240        </div>
24241      </div>
24242
24243      <div class="ep-card">
24244        <div class="ep-header">
24245          <span class="method post">POST</span>
24246          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
24247          <span class="auth-badge protected">Protected</span>
24248          <span class="ep-desc">Cancel a running scan</span>
24249          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24250        </div>
24251        <div class="ep-body">
24252          <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>
24253          <p class="curl-heading">Example</p>
24254          <div class="curl-wrap">
24255            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
24256  -H "Authorization: Bearer $SLOC_API_KEY" \
24257  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
24258            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
24259          </div>
24260        </div>
24261      </div>
24262    </div>
24263
24264    <!-- Run Management -->
24265    <div class="section">
24266      <h2 class="section-title">Run Management</h2>
24267
24268      <div class="ep-card">
24269        <div class="ep-header">
24270          <span class="method get">GET</span>
24271          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
24272          <span class="auth-badge protected">Protected</span>
24273          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</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 <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>
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">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>
24282          </table>
24283          <details class="schema"><summary>Response</summary>
24284<div class="schema-block">200 OK — Content-Type: application/zip
24285Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
24286
24287404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
24288          <p class="curl-heading">Example</p>
24289          <div class="curl-wrap">
24290            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24291  -o run.zip \
24292  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
24293            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
24294          </div>
24295        </div>
24296      </div>
24297
24298      <div class="ep-card">
24299        <div class="ep-header">
24300          <span class="method delete">DELETE</span>
24301          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
24302          <span class="auth-badge protected">Protected</span>
24303          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
24304          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24305        </div>
24306        <div class="ep-body">
24307          <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>
24308          <p class="params-heading">Path Parameters</p>
24309          <table class="params">
24310            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24311            <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>
24312          </table>
24313          <details class="schema"><summary>Response</summary>
24314<div class="schema-block">204 No Content — run successfully deleted
24315
24316500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
24317          <p class="curl-heading">Example</p>
24318          <div class="curl-wrap">
24319            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
24320  -H "Authorization: Bearer $SLOC_API_KEY" \
24321  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
24322            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
24323          </div>
24324        </div>
24325      </div>
24326
24327      <div class="ep-card">
24328        <div class="ep-header">
24329          <span class="method post">POST</span>
24330          <span class="ep-path">/api/runs/cleanup</span>
24331          <span class="auth-badge protected">Protected</span>
24332          <span class="ep-desc">Bulk delete runs older than N days</span>
24333          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24334        </div>
24335        <div class="ep-body">
24336          <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>
24337          <p class="params-heading">Request Body (application/json)</p>
24338          <table class="params">
24339            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24340            <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>
24341          </table>
24342          <details class="schema"><summary>Response schema</summary>
24343<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
24344          <p class="curl-heading">Example — delete runs older than 60 days</p>
24345          <div class="curl-wrap">
24346            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
24347  -H "Authorization: Bearer $SLOC_API_KEY" \
24348  -H "Content-Type: application/json" \
24349  -d '{"older_than_days":60}' \
24350  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
24351            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
24352          </div>
24353        </div>
24354      </div>
24355    </div>
24356
24357    <!-- Retention Policy -->
24358    <div class="section">
24359      <h2 class="section-title">Retention Policy</h2>
24360
24361      <div class="ep-card">
24362        <div class="ep-header">
24363          <span class="method get">GET</span>
24364          <span class="ep-path">/api/cleanup-policy</span>
24365          <span class="auth-badge protected">Protected</span>
24366          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
24367          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24368        </div>
24369        <div class="ep-body">
24370          <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>
24371          <details class="schema"><summary>Response schema</summary>
24372<div class="schema-block">{
24373  "policy": {
24374    "enabled":       boolean,
24375    "max_age_days":  number | null,   // delete runs older than N days
24376    "max_run_count": number | null,   // keep only the N most recent runs
24377    "interval_hours": number          // hours between background passes
24378  } | null,
24379  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
24380  "last_run_deleted": number | null   // runs deleted in last pass
24381}</div></details>
24382          <p class="curl-heading">Example</p>
24383          <div class="curl-wrap">
24384            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24385  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24386            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
24387          </div>
24388        </div>
24389      </div>
24390
24391      <div class="ep-card">
24392        <div class="ep-header">
24393          <span class="method post">POST</span>
24394          <span class="ep-path">/api/cleanup-policy</span>
24395          <span class="auth-badge protected">Protected</span>
24396          <span class="ep-desc">Save or update the retention policy</span>
24397          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24398        </div>
24399        <div class="ep-body">
24400          <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>
24401          <p class="params-heading">Request Body (application/json)</p>
24402          <table class="params">
24403            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24404            <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>
24405            <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>
24406            <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>
24407            <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>
24408          </table>
24409          <details class="schema"><summary>Response</summary>
24410<div class="schema-block">204 No Content — policy saved and task (re)started
24411
24412500 Internal Server Error — { "error": string }</div></details>
24413          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
24414          <div class="curl-wrap">
24415            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
24416  -H "Authorization: Bearer $SLOC_API_KEY" \
24417  -H "Content-Type: application/json" \
24418  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
24419  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24420            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
24421          </div>
24422        </div>
24423      </div>
24424
24425      <div class="ep-card">
24426        <div class="ep-header">
24427          <span class="method post">POST</span>
24428          <span class="ep-path">/api/cleanup-policy/run-now</span>
24429          <span class="auth-badge protected">Protected</span>
24430          <span class="ep-desc">Trigger an immediate cleanup pass</span>
24431          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24432        </div>
24433        <div class="ep-body">
24434          <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>
24435          <details class="schema"><summary>Response schema</summary>
24436<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
24437          <p class="curl-heading">Example</p>
24438          <div class="curl-wrap">
24439            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
24440  -H "Authorization: Bearer $SLOC_API_KEY" \
24441  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
24442            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
24443          </div>
24444        </div>
24445      </div>
24446
24447      <div class="ep-card">
24448        <div class="ep-header">
24449          <span class="method delete">DELETE</span>
24450          <span class="ep-path">/api/cleanup-policy</span>
24451          <span class="auth-badge protected">Protected</span>
24452          <span class="ep-desc">Remove the retention policy and stop the background task</span>
24453          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24454        </div>
24455        <div class="ep-body">
24456          <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>
24457          <details class="schema"><summary>Response</summary>
24458<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
24459          <p class="curl-heading">Example</p>
24460          <div class="curl-wrap">
24461            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
24462  -H "Authorization: Bearer $SLOC_API_KEY" \
24463  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
24464            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
24465          </div>
24466        </div>
24467      </div>
24468    </div>
24469
24470    <!-- Scan Profiles -->
24471    <div class="section">
24472      <h2 class="section-title">Scan Profiles</h2>
24473
24474      <div class="ep-card">
24475        <div class="ep-header">
24476          <span class="method get">GET</span>
24477          <span class="ep-path">/api/scan-profiles</span>
24478          <span class="auth-badge protected">Protected</span>
24479          <span class="ep-desc">List saved scan profiles</span>
24480          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24481        </div>
24482        <div class="ep-body">
24483          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
24484          <details class="schema"><summary>Response schema</summary>
24485<div class="schema-block">{
24486  "profiles": [{
24487    "id":         string,   // UUID
24488    "name":       string,
24489    "created_at": string,   // ISO-8601
24490    "params":     object
24491  }]
24492}</div></details>
24493          <p class="curl-heading">Example</p>
24494          <div class="curl-wrap">
24495            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24496  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24497            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
24498          </div>
24499        </div>
24500      </div>
24501
24502      <div class="ep-card">
24503        <div class="ep-header">
24504          <span class="method post">POST</span>
24505          <span class="ep-path">/api/scan-profiles</span>
24506          <span class="auth-badge protected">Protected</span>
24507          <span class="ep-desc">Save a scan profile</span>
24508          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24509        </div>
24510        <div class="ep-body">
24511          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
24512          <p class="params-heading">Request Body (application/json)</p>
24513          <table class="params">
24514            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24515            <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>
24516            <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>
24517          </table>
24518          <details class="schema"><summary>Response schema</summary>
24519<div class="schema-block">{ "ok": true }</div></details>
24520          <p class="curl-heading">Example</p>
24521          <div class="curl-wrap">
24522            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
24523  -H "Authorization: Bearer $SLOC_API_KEY" \
24524  -H "Content-Type: application/json" \
24525  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
24526  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
24527            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
24528          </div>
24529        </div>
24530      </div>
24531
24532      <div class="ep-card">
24533        <div class="ep-header">
24534          <span class="method delete">DELETE</span>
24535          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
24536          <span class="auth-badge protected">Protected</span>
24537          <span class="ep-desc">Delete a scan profile</span>
24538          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24539        </div>
24540        <div class="ep-body">
24541          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
24542          <p class="params-heading">Path Parameters</p>
24543          <table class="params">
24544            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24545            <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>
24546          </table>
24547          <details class="schema"><summary>Response schema</summary>
24548<div class="schema-block">{ "ok": true }</div></details>
24549          <p class="curl-heading">Example</p>
24550          <div class="curl-wrap">
24551            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
24552  -H "Authorization: Bearer $SLOC_API_KEY" \
24553  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
24554            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
24555          </div>
24556        </div>
24557      </div>
24558    </div>
24559
24560    <!-- Scheduled Scans -->
24561    <div class="section">
24562      <h2 class="section-title">Scheduled Scans</h2>
24563
24564      <div class="ep-card">
24565        <div class="ep-header">
24566          <span class="method get">GET</span>
24567          <span class="ep-path">/api/schedules</span>
24568          <span class="auth-badge protected">Protected</span>
24569          <span class="ep-desc">List configured schedules</span>
24570          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24571        </div>
24572        <div class="ep-body">
24573          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
24574          <p class="curl-heading">Example</p>
24575          <div class="curl-wrap">
24576            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24577  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24578            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
24579          </div>
24580        </div>
24581      </div>
24582
24583      <div class="ep-card">
24584        <div class="ep-header">
24585          <span class="method post">POST</span>
24586          <span class="ep-path">/api/schedules</span>
24587          <span class="auth-badge protected">Protected</span>
24588          <span class="ep-desc">Create a schedule</span>
24589          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24590        </div>
24591        <div class="ep-body">
24592          <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>
24593          <p class="curl-heading">Example</p>
24594          <div class="curl-wrap">
24595            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
24596  -H "Authorization: Bearer $SLOC_API_KEY" \
24597  -H "Content-Type: application/json" \
24598  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
24599  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24600            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
24601          </div>
24602        </div>
24603      </div>
24604
24605      <div class="ep-card">
24606        <div class="ep-header">
24607          <span class="method delete">DELETE</span>
24608          <span class="ep-path">/api/schedules</span>
24609          <span class="auth-badge protected">Protected</span>
24610          <span class="ep-desc">Delete a schedule</span>
24611          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24612        </div>
24613        <div class="ep-body">
24614          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
24615          <p class="curl-heading">Example</p>
24616          <div class="curl-wrap">
24617            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
24618  -H "Authorization: Bearer $SLOC_API_KEY" \
24619  -H "Content-Type: application/json" \
24620  -d '{"id":"&lt;schedule_id&gt;"}' \
24621  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
24622            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
24623          </div>
24624        </div>
24625      </div>
24626    </div>
24627
24628    <!-- Git Browser -->
24629    <div class="section">
24630      <h2 class="section-title">Git Browser</h2>
24631
24632      <div class="ep-card">
24633        <div class="ep-header">
24634          <span class="method get">GET</span>
24635          <span class="ep-path">/api/git/refs</span>
24636          <span class="auth-badge protected">Protected</span>
24637          <span class="ep-desc">List git refs for a repository</span>
24638          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24639        </div>
24640        <div class="ep-body">
24641          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
24642          <p class="params-heading">Query Parameters</p>
24643          <table class="params">
24644            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24645            <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>
24646          </table>
24647          <p class="curl-heading">Example</p>
24648          <div class="curl-wrap">
24649            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24650  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
24651            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
24652          </div>
24653        </div>
24654      </div>
24655
24656      <div class="ep-card">
24657        <div class="ep-header">
24658          <span class="method get">GET</span>
24659          <span class="ep-path">/api/git/scan-ref</span>
24660          <span class="auth-badge protected">Protected</span>
24661          <span class="ep-desc">SLOC-scan a specific git ref</span>
24662          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24663        </div>
24664        <div class="ep-body">
24665          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
24666          <p class="params-heading">Query Parameters</p>
24667          <table class="params">
24668            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24669            <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>
24670            <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>
24671          </table>
24672          <p class="curl-heading">Example</p>
24673          <div class="curl-wrap">
24674            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24675  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
24676            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
24677          </div>
24678        </div>
24679      </div>
24680
24681      <div class="ep-card">
24682        <div class="ep-header">
24683          <span class="method get">GET</span>
24684          <span class="ep-path">/api/git/compare-refs</span>
24685          <span class="auth-badge protected">Protected</span>
24686          <span class="ep-desc">Compare SLOC across two git refs</span>
24687          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24688        </div>
24689        <div class="ep-body">
24690          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
24691          <p class="params-heading">Query Parameters</p>
24692          <table class="params">
24693            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24694            <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>
24695            <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>
24696            <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>
24697          </table>
24698          <p class="curl-heading">Example</p>
24699          <div class="curl-wrap">
24700            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24701  "<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>
24702            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
24703          </div>
24704        </div>
24705      </div>
24706    </div>
24707
24708    <!-- Webhooks -->
24709    <div class="section">
24710      <h2 class="section-title">Webhooks</h2>
24711      <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>
24712
24713      <div class="ep-card">
24714        <div class="ep-header">
24715          <span class="method post">POST</span>
24716          <span class="ep-path">/webhooks/github</span>
24717          <span class="auth-badge hmac">HMAC</span>
24718          <span class="ep-desc">GitHub push event receiver</span>
24719          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24720        </div>
24721        <div class="ep-body">
24722          <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>
24723          <p class="params-heading">Required Headers</p>
24724          <table class="params">
24725            <tr><th>Header</th><th>Value</th></tr>
24726            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
24727            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
24728            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24729          </table>
24730        </div>
24731      </div>
24732
24733      <div class="ep-card">
24734        <div class="ep-header">
24735          <span class="method post">POST</span>
24736          <span class="ep-path">/webhooks/gitlab</span>
24737          <span class="auth-badge hmac">HMAC</span>
24738          <span class="ep-desc">GitLab push event receiver</span>
24739          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24740        </div>
24741        <div class="ep-body">
24742          <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>
24743          <p class="params-heading">Required Headers</p>
24744          <table class="params">
24745            <tr><th>Header</th><th>Value</th></tr>
24746            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
24747            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
24748            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24749          </table>
24750        </div>
24751      </div>
24752
24753      <div class="ep-card">
24754        <div class="ep-header">
24755          <span class="method post">POST</span>
24756          <span class="ep-path">/webhooks/bitbucket</span>
24757          <span class="auth-badge hmac">HMAC</span>
24758          <span class="ep-desc">Bitbucket push event receiver</span>
24759          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24760        </div>
24761        <div class="ep-body">
24762          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
24763          <p class="params-heading">Required Headers</p>
24764          <table class="params">
24765            <tr><th>Header</th><th>Value</th></tr>
24766            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
24767            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
24768          </table>
24769        </div>
24770      </div>
24771    </div>
24772
24773    <!-- Config -->
24774    <div class="section">
24775      <h2 class="section-title">Config Import / Export</h2>
24776
24777      <div class="ep-card">
24778        <div class="ep-header">
24779          <span class="method get">GET</span>
24780          <span class="ep-path">/export-config</span>
24781          <span class="auth-badge protected">Protected</span>
24782          <span class="ep-desc">Export server configuration as JSON</span>
24783          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24784        </div>
24785        <div class="ep-body">
24786          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
24787          <p class="curl-heading">Example</p>
24788          <div class="curl-wrap">
24789            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24790  -o config.json \
24791  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
24792            <button class="curl-copy-btn" data-target="c-export">Copy</button>
24793          </div>
24794        </div>
24795      </div>
24796
24797      <div class="ep-card">
24798        <div class="ep-header">
24799          <span class="method post">POST</span>
24800          <span class="ep-path">/import-config</span>
24801          <span class="auth-badge protected">Protected</span>
24802          <span class="ep-desc">Import server configuration</span>
24803          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24804        </div>
24805        <div class="ep-body">
24806          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
24807          <p class="curl-heading">Example</p>
24808          <div class="curl-wrap">
24809            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
24810  -H "Authorization: Bearer $SLOC_API_KEY" \
24811  -H "Content-Type: application/json" \
24812  -d @config.json \
24813  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
24814            <button class="curl-copy-btn" data-target="c-import">Copy</button>
24815          </div>
24816        </div>
24817      </div>
24818    </div>
24819
24820    <!-- CI Ingest -->
24821    <div class="section">
24822      <h2 class="section-title">CI Ingest</h2>
24823
24824      <div class="ep-card">
24825        <div class="ep-header">
24826          <span class="method post">POST</span>
24827          <span class="ep-path">/api/ingest</span>
24828          <span class="auth-badge protected">Protected</span>
24829          <span class="ep-desc">Push a pre-computed scan result from CI</span>
24830          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24831        </div>
24832        <div class="ep-body">
24833          <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>
24834          <p class="params-heading">Query Parameters</p>
24835          <table class="params">
24836            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24837            <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>
24838          </table>
24839          <p class="params-heading">Request Body (application/json)</p>
24840          <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>
24841          <details class="schema"><summary>Response schema</summary>
24842<div class="schema-block">// 201 Created
24843{
24844  "run_id":   string,  // UUID of the ingested run
24845  "view_url": string   // relative URL to the report page
24846}</div></details>
24847          <p class="curl-heading">Example</p>
24848          <div class="curl-wrap">
24849            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
24850  -H "Authorization: Bearer $SLOC_API_KEY" \
24851  -H "Content-Type: application/json" \
24852  -d @result.json \
24853  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
24854            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
24855          </div>
24856        </div>
24857      </div>
24858    </div>
24859
24860    <!-- Artifact Download -->
24861    <div class="section">
24862      <h2 class="section-title">Artifact Download</h2>
24863
24864      <div class="ep-card">
24865        <div class="ep-header">
24866          <span class="method get">GET</span>
24867          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
24868          <span class="auth-badge protected">Protected</span>
24869          <span class="ep-desc">Download or view a scan artifact</span>
24870          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24871        </div>
24872        <div class="ep-body">
24873          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
24874          <p class="params-heading">Path Parameters</p>
24875          <table class="params">
24876            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24877            <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>
24878            <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>
24879          </table>
24880          <p class="params-heading">Query Parameters</p>
24881          <table class="params">
24882            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24883            <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>
24884          </table>
24885          <p class="curl-heading">Example — download JSON result</p>
24886          <div class="curl-wrap">
24887            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24888  -o result.json \
24889  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
24890            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
24891          </div>
24892        </div>
24893      </div>
24894    </div>
24895
24896    <!-- Embed Widget -->
24897    <div class="section">
24898      <h2 class="section-title">Embed Widget</h2>
24899
24900      <div class="ep-card">
24901        <div class="ep-header">
24902          <span class="method get">GET</span>
24903          <span class="ep-path">/embed/summary</span>
24904          <span class="auth-badge protected">Protected</span>
24905          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
24906          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24907        </div>
24908        <div class="ep-body">
24909          <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>
24910          <p class="params-heading">Query Parameters</p>
24911          <table class="params">
24912            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
24913            <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>
24914            <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>
24915          </table>
24916          <p class="curl-heading">Example</p>
24917          <div class="curl-wrap">
24918            <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"
24919        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
24920            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
24921          </div>
24922        </div>
24923      </div>
24924    </div>
24925
24926    <!-- Confluence Integration -->
24927    <div class="section">
24928      <h2 class="section-title">Confluence Integration</h2>
24929
24930      <div class="ep-card">
24931        <div class="ep-header">
24932          <span class="method get">GET</span>
24933          <span class="ep-path">/api/confluence/config</span>
24934          <span class="auth-badge protected">Protected</span>
24935          <span class="ep-desc">Get current Confluence configuration</span>
24936          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24937        </div>
24938        <div class="ep-body">
24939          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
24940          <details class="schema"><summary>Response schema</summary>
24941<div class="schema-block">{
24942  "configured":     boolean,
24943  "tier":           "cloud" | "server",
24944  "base_url":       string,
24945  "username":       string,
24946  "api_token_set":  boolean,
24947  "space_key":      string,
24948  "parent_page_id": string | null,
24949  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
24950}</div></details>
24951          <p class="curl-heading">Example</p>
24952          <div class="curl-wrap">
24953            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
24954  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
24955            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
24956          </div>
24957        </div>
24958      </div>
24959
24960      <div class="ep-card">
24961        <div class="ep-header">
24962          <span class="method post">POST</span>
24963          <span class="ep-path">/api/confluence/config</span>
24964          <span class="auth-badge protected">Protected</span>
24965          <span class="ep-desc">Save Confluence configuration</span>
24966          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
24967        </div>
24968        <div class="ep-body">
24969          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
24970          <p class="params-heading">Request Body (application/json)</p>
24971          <table class="params">
24972            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
24973            <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>
24974            <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>
24975            <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>
24976            <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>
24977            <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>
24978            <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>
24979            <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>
24980          </table>
24981          <details class="schema"><summary>Response schema</summary>
24982<div class="schema-block">{ "ok": true }</div></details>
24983          <p class="curl-heading">Example</p>
24984          <div class="curl-wrap">
24985            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
24986  -H "Authorization: Bearer $SLOC_API_KEY" \
24987  -H "Content-Type: application/json" \
24988  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
24989  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
24990            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
24991          </div>
24992        </div>
24993      </div>
24994
24995      <div class="ep-card">
24996        <div class="ep-header">
24997          <span class="method post">POST</span>
24998          <span class="ep-path">/api/confluence/test</span>
24999          <span class="auth-badge protected">Protected</span>
25000          <span class="ep-desc">Test Confluence connection</span>
25001          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25002        </div>
25003        <div class="ep-body">
25004          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
25005          <details class="schema"><summary>Response schema</summary>
25006<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
25007          <p class="curl-heading">Example</p>
25008          <div class="curl-wrap">
25009            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
25010  -H "Authorization: Bearer $SLOC_API_KEY" \
25011  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
25012            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
25013          </div>
25014        </div>
25015      </div>
25016
25017      <div class="ep-card">
25018        <div class="ep-header">
25019          <span class="method post">POST</span>
25020          <span class="ep-path">/api/confluence/post</span>
25021          <span class="auth-badge protected">Protected</span>
25022          <span class="ep-desc">Publish a scan report to Confluence</span>
25023          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25024        </div>
25025        <div class="ep-body">
25026          <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>
25027          <p class="params-heading">Request Body (application/json)</p>
25028          <table class="params">
25029            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25030            <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>
25031            <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>
25032            <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>
25033          </table>
25034          <details class="schema"><summary>Response schema</summary>
25035<div class="schema-block">// 200 OK
25036{ "ok": true, "page_id": string }
25037
25038// 400 / 502 on error
25039{ "ok": false, "error": string }</div></details>
25040          <p class="curl-heading">Example</p>
25041          <div class="curl-wrap">
25042            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
25043  -H "Authorization: Bearer $SLOC_API_KEY" \
25044  -H "Content-Type: application/json" \
25045  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
25046  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
25047            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
25048          </div>
25049        </div>
25050      </div>
25051
25052      <div class="ep-card">
25053        <div class="ep-header">
25054          <span class="method get">GET</span>
25055          <span class="ep-path">/api/confluence/wiki-markup</span>
25056          <span class="auth-badge protected">Protected</span>
25057          <span class="ep-desc">Get Confluence wiki markup for a run</span>
25058          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25059        </div>
25060        <div class="ep-body">
25061          <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>
25062          <p class="params-heading">Query Parameters</p>
25063          <table class="params">
25064            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25065            <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>
25066          </table>
25067          <p class="curl-heading">Example</p>
25068          <div class="curl-wrap">
25069            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25070  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
25071            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
25072          </div>
25073        </div>
25074      </div>
25075    </div>
25076
25077    <!-- Authentication -->
25078    <div class="section">
25079      <h2 class="section-title">Authentication</h2>
25080      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
25081
25082      <div class="ep-card">
25083        <div class="ep-header">
25084          <span class="method get">GET</span>
25085          <span class="ep-path">/auth/login</span>
25086          <span class="auth-badge public">Public</span>
25087          <span class="ep-desc">Login page</span>
25088          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25089        </div>
25090        <div class="ep-body">
25091          <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>
25092          <p class="params-heading">Query Parameters</p>
25093          <table class="params">
25094            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25095            <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>
25096            <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>
25097          </table>
25098        </div>
25099      </div>
25100
25101      <div class="ep-card">
25102        <div class="ep-header">
25103          <span class="method post">POST</span>
25104          <span class="ep-path">/auth/login</span>
25105          <span class="auth-badge public">Public</span>
25106          <span class="ep-desc">Submit credentials and get a session cookie</span>
25107          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25108        </div>
25109        <div class="ep-body">
25110          <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>
25111          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
25112          <table class="params">
25113            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
25114            <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>
25115            <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>
25116          </table>
25117          <p class="curl-heading">Example</p>
25118          <div class="curl-wrap">
25119            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
25120  -d "key=$SLOC_API_KEY&amp;next=/" \
25121  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
25122            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
25123          </div>
25124        </div>
25125      </div>
25126    </div>
25127
25128    <!-- Coverage Suggestion -->
25129    <div class="section">
25130      <h2 class="section-title">Coverage Suggestion</h2>
25131
25132      <div class="ep-card">
25133        <div class="ep-header">
25134          <span class="method get">GET</span>
25135          <span class="ep-path">/api/suggest-coverage</span>
25136          <span class="auth-badge protected">Protected</span>
25137          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
25138          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25139        </div>
25140        <div class="ep-body">
25141          <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>
25142          <p class="params-heading">Query Parameters</p>
25143          <table class="params">
25144            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
25145            <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>
25146          </table>
25147          <details class="schema"><summary>Response schema</summary>
25148<div class="schema-block">{
25149  "found": string | null,  // absolute path to the coverage file, if detected
25150  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
25151  "hint":  string | null   // shell command to generate coverage if not found
25152}</div></details>
25153          <p class="curl-heading">Example</p>
25154          <div class="curl-wrap">
25155            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
25156  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
25157            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
25158          </div>
25159        </div>
25160      </div>
25161    </div>
25162
25163  </div>
25164
25165  <footer class="site-footer">
25166    local code analysis - metrics, history and reports
25167    &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>
25168    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25169    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25170    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25171    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25172  </footer>
25173
25174  <script nonce="{{ csp_nonce }}">
25175    (function () {
25176      var base = window.location.origin;
25177      document.getElementById('base-url').textContent = base;
25178      document.querySelectorAll('.base-url-slot').forEach(function (el) {
25179        el.textContent = base;
25180      });
25181
25182      document.querySelectorAll('.ep-header').forEach(function (hdr) {
25183        hdr.addEventListener('click', function () {
25184          hdr.closest('.ep-card').classList.toggle('open');
25185        });
25186      });
25187
25188      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
25189        btn.addEventListener('click', function () {
25190          var targetId = btn.dataset.target;
25191          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
25192          if (!pre) return;
25193          navigator.clipboard.writeText(pre.textContent).then(function () {
25194            btn.textContent = 'Copied!';
25195            btn.classList.add('copied');
25196            setTimeout(function () {
25197              btn.textContent = 'Copy';
25198              btn.classList.remove('copied');
25199            }, 2000);
25200          });
25201        });
25202      });
25203
25204      var storageKey = 'oxide-sloc-theme';
25205      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
25206      var themeBtn = document.getElementById('theme-toggle');
25207      if (themeBtn) {
25208        themeBtn.addEventListener('click', function () {
25209          var dark = document.body.classList.toggle('dark-theme');
25210          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
25211        });
25212      }
25213      (function() {
25214        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'}];
25215        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);});}
25216        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25217        var btn=document.getElementById('settings-btn');if(!btn)return;
25218        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25219        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>';
25220        document.body.appendChild(m);
25221        var g=document.getElementById('scheme-grid');
25222        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);});
25223        var cl=document.getElementById('settings-close');
25224        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);
25225        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');});
25226        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25227        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25228      })();
25229      (function randomizeWatermarks() {
25230        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25231        if (!wms.length) return;
25232        var placed = [];
25233        function tooClose(top, left) {
25234          for (var i = 0; i < placed.length; i++) {
25235            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25236            if (dt < 16 && dl < 12) return true;
25237          }
25238          return false;
25239        }
25240        function pick(leftBand) {
25241          for (var attempt = 0; attempt < 50; attempt++) {
25242            var top = Math.random() * 88 + 2;
25243            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25244            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25245          }
25246          var top = Math.random() * 88 + 2;
25247          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25248          placed.push([top, left]); return [top, left];
25249        }
25250        var half = Math.floor(wms.length / 2);
25251        wms.forEach(function (img, i) {
25252          var pos = pick(i < half);
25253          var size = Math.floor(Math.random() * 100 + 120);
25254          var rot = (Math.random() * 360).toFixed(1);
25255          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25256          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;
25257        });
25258      })();
25259      (function spawnCodeParticles() {
25260        var container = document.getElementById('code-particles');
25261        if (!container) return;
25262        var snippets = [
25263          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
25264          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
25265          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
25266          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
25267          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
25268        ];
25269        var count = 38;
25270        for (var i = 0; i < count; i++) {
25271          (function(idx) {
25272            var el = document.createElement('span');
25273            el.className = 'code-particle';
25274            el.textContent = snippets[idx % snippets.length];
25275            var left = Math.random() * 94 + 2;
25276            var top = Math.random() * 88 + 6;
25277            var dur = (Math.random() * 10 + 9).toFixed(1);
25278            var delay = (Math.random() * 18).toFixed(1);
25279            var rot = (Math.random() * 26 - 13).toFixed(1);
25280            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25281            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
25282            container.appendChild(el);
25283          })(i);
25284        }
25285      })();
25286    }());
25287  </script>
25288</body>
25289</html>
25290"##,
25291    ext = "html"
25292)]
25293struct ApiDocsTemplate {
25294    has_api_key: bool,
25295    csp_nonce: String,
25296    version: &'static str,
25297}
25298
25299#[cfg(test)]
25300mod form_config_tests {
25301    use super::*;
25302    use sloc_config::{
25303        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
25304    };
25305
25306    fn blank_form() -> AnalyzeForm {
25307        AnalyzeForm {
25308            path: ".".to_string(),
25309            git_repo: None,
25310            git_ref: None,
25311            mixed_line_policy: None,
25312            python_docstrings_as_comments: None,
25313            generated_file_detection: None,
25314            minified_file_detection: None,
25315            vendor_directory_detection: None,
25316            include_lockfiles: None,
25317            binary_file_behavior: None,
25318            output_dir: None,
25319            report_title: None,
25320            report_header_footer: None,
25321            include_globs: None,
25322            exclude_globs: None,
25323            submodule_breakdown: None,
25324            coverage_file: None,
25325            continuation_line_policy: None,
25326            blank_in_block_comment_policy: None,
25327            count_compiler_directives: None,
25328            style_col_threshold: None,
25329            style_analysis_enabled: None,
25330            style_score_threshold: None,
25331            style_lang_scope: None,
25332        }
25333    }
25334
25335    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
25336        let mut cfg = sloc_config::AppConfig::default();
25337        apply_form_to_config(&mut cfg, form);
25338        cfg
25339    }
25340
25341    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
25342
25343    #[test]
25344    fn python_docstrings_false_when_unchecked() {
25345        // Checkbox absent in form data (unchecked) → field must be false.
25346        let cfg = apply(&blank_form());
25347        assert!(
25348            !cfg.analysis.python_docstrings_as_comments,
25349            "absent python_docstrings_as_comments must map to false"
25350        );
25351    }
25352
25353    #[test]
25354    fn python_docstrings_true_when_checked() {
25355        // Browser sends "on" (no value= attr on the checkbox).
25356        let mut form = blank_form();
25357        form.python_docstrings_as_comments = Some("on".to_string());
25358        let cfg = apply(&form);
25359        assert!(cfg.analysis.python_docstrings_as_comments);
25360    }
25361
25362    #[test]
25363    fn python_docstrings_true_for_any_non_none_value() {
25364        // The handler uses .is_some() — any non-None value means "checked".
25365        let mut form = blank_form();
25366        form.python_docstrings_as_comments = Some("true".to_string());
25367        assert!(apply(&form).analysis.python_docstrings_as_comments);
25368    }
25369
25370    // ── submodule_breakdown (checkbox with value="enabled") ──
25371
25372    #[test]
25373    fn submodule_breakdown_false_when_unchecked() {
25374        let cfg = apply(&blank_form());
25375        assert!(
25376            !cfg.discovery.submodule_breakdown,
25377            "absent submodule_breakdown must map to false"
25378        );
25379    }
25380
25381    #[test]
25382    fn submodule_breakdown_true_when_value_enabled() {
25383        let mut form = blank_form();
25384        form.submodule_breakdown = Some("enabled".to_string());
25385        assert!(apply(&form).discovery.submodule_breakdown);
25386    }
25387
25388    #[test]
25389    fn submodule_breakdown_false_for_wrong_value() {
25390        // If somehow a value other than "enabled" is sent, it must still be false.
25391        let mut form = blank_form();
25392        form.submodule_breakdown = Some("on".to_string());
25393        assert!(
25394            !apply(&form).discovery.submodule_breakdown,
25395            "submodule_breakdown only becomes true for the exact value 'enabled'"
25396        );
25397    }
25398
25399    // ── generated_file_detection (select: "enabled" | "disabled") ──
25400
25401    #[test]
25402    fn generated_detection_true_when_enabled() {
25403        let mut form = blank_form();
25404        form.generated_file_detection = Some("enabled".to_string());
25405        assert!(apply(&form).analysis.generated_file_detection);
25406    }
25407
25408    #[test]
25409    fn generated_detection_false_when_disabled() {
25410        let mut form = blank_form();
25411        form.generated_file_detection = Some("disabled".to_string());
25412        assert!(!apply(&form).analysis.generated_file_detection);
25413    }
25414
25415    #[test]
25416    fn generated_detection_true_when_absent() {
25417        // None != Some("disabled") → true (safe default)
25418        assert!(
25419            apply(&blank_form()).analysis.generated_file_detection,
25420            "absent field must default to true (detection on)"
25421        );
25422    }
25423
25424    // ── minified_file_detection ──
25425
25426    #[test]
25427    fn minified_detection_false_when_disabled() {
25428        let mut form = blank_form();
25429        form.minified_file_detection = Some("disabled".to_string());
25430        assert!(!apply(&form).analysis.minified_file_detection);
25431    }
25432
25433    #[test]
25434    fn minified_detection_true_when_enabled() {
25435        let mut form = blank_form();
25436        form.minified_file_detection = Some("enabled".to_string());
25437        assert!(apply(&form).analysis.minified_file_detection);
25438    }
25439
25440    #[test]
25441    fn minified_detection_true_when_absent() {
25442        assert!(apply(&blank_form()).analysis.minified_file_detection);
25443    }
25444
25445    // ── vendor_directory_detection ──
25446
25447    #[test]
25448    fn vendor_detection_false_when_disabled() {
25449        let mut form = blank_form();
25450        form.vendor_directory_detection = Some("disabled".to_string());
25451        assert!(!apply(&form).analysis.vendor_directory_detection);
25452    }
25453
25454    #[test]
25455    fn vendor_detection_true_when_enabled() {
25456        let mut form = blank_form();
25457        form.vendor_directory_detection = Some("enabled".to_string());
25458        assert!(apply(&form).analysis.vendor_directory_detection);
25459    }
25460
25461    #[test]
25462    fn vendor_detection_true_when_absent() {
25463        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
25464    }
25465
25466    // ── include_lockfiles (select: "disabled" default | "enabled") ──
25467
25468    #[test]
25469    fn lockfiles_false_when_absent() {
25470        // None == Some("enabled") is false → lockfiles off (correct safe default)
25471        assert!(!apply(&blank_form()).analysis.include_lockfiles);
25472    }
25473
25474    #[test]
25475    fn lockfiles_false_when_disabled() {
25476        let mut form = blank_form();
25477        form.include_lockfiles = Some("disabled".to_string());
25478        assert!(!apply(&form).analysis.include_lockfiles);
25479    }
25480
25481    #[test]
25482    fn lockfiles_true_when_enabled() {
25483        let mut form = blank_form();
25484        form.include_lockfiles = Some("enabled".to_string());
25485        assert!(apply(&form).analysis.include_lockfiles);
25486    }
25487
25488    // ── count_compiler_directives ──
25489
25490    #[test]
25491    fn compiler_directives_true_when_absent() {
25492        assert!(
25493            apply(&blank_form()).analysis.count_compiler_directives,
25494            "absent count_compiler_directives must default to true"
25495        );
25496    }
25497
25498    #[test]
25499    fn compiler_directives_true_when_enabled() {
25500        let mut form = blank_form();
25501        form.count_compiler_directives = Some("enabled".to_string());
25502        assert!(apply(&form).analysis.count_compiler_directives);
25503    }
25504
25505    #[test]
25506    fn compiler_directives_false_when_disabled() {
25507        let mut form = blank_form();
25508        form.count_compiler_directives = Some("disabled".to_string());
25509        assert!(!apply(&form).analysis.count_compiler_directives);
25510    }
25511
25512    // ── mixed_line_policy (enum select) ──
25513
25514    #[test]
25515    fn mixed_policy_unchanged_when_absent() {
25516        // None → if-let does nothing → stays at config default (CodeOnly)
25517        assert_eq!(
25518            apply(&blank_form()).analysis.mixed_line_policy,
25519            MixedLinePolicy::CodeOnly
25520        );
25521    }
25522
25523    #[test]
25524    fn mixed_policy_code_only() {
25525        let mut form = blank_form();
25526        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
25527        assert_eq!(
25528            apply(&form).analysis.mixed_line_policy,
25529            MixedLinePolicy::CodeOnly
25530        );
25531    }
25532
25533    #[test]
25534    fn mixed_policy_code_and_comment() {
25535        let mut form = blank_form();
25536        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
25537        assert_eq!(
25538            apply(&form).analysis.mixed_line_policy,
25539            MixedLinePolicy::CodeAndComment
25540        );
25541    }
25542
25543    #[test]
25544    fn mixed_policy_comment_only() {
25545        let mut form = blank_form();
25546        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
25547        assert_eq!(
25548            apply(&form).analysis.mixed_line_policy,
25549            MixedLinePolicy::CommentOnly
25550        );
25551    }
25552
25553    #[test]
25554    fn mixed_policy_separate_mixed_category() {
25555        let mut form = blank_form();
25556        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
25557        assert_eq!(
25558            apply(&form).analysis.mixed_line_policy,
25559            MixedLinePolicy::SeparateMixedCategory
25560        );
25561    }
25562
25563    // ── binary_file_behavior (enum select) ──
25564
25565    #[test]
25566    fn binary_behavior_skip_when_absent() {
25567        assert_eq!(
25568            apply(&blank_form()).analysis.binary_file_behavior,
25569            BinaryFileBehavior::Skip
25570        );
25571    }
25572
25573    #[test]
25574    fn binary_behavior_skip() {
25575        let mut form = blank_form();
25576        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
25577        assert_eq!(
25578            apply(&form).analysis.binary_file_behavior,
25579            BinaryFileBehavior::Skip
25580        );
25581    }
25582
25583    #[test]
25584    fn binary_behavior_fail() {
25585        let mut form = blank_form();
25586        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
25587        assert_eq!(
25588            apply(&form).analysis.binary_file_behavior,
25589            BinaryFileBehavior::Fail
25590        );
25591    }
25592
25593    // ── continuation_line_policy (enum select) ──
25594
25595    #[test]
25596    fn continuation_policy_each_physical_when_absent() {
25597        assert_eq!(
25598            apply(&blank_form()).analysis.continuation_line_policy,
25599            ContinuationLinePolicy::EachPhysicalLine
25600        );
25601    }
25602
25603    #[test]
25604    fn continuation_policy_collapse_to_logical() {
25605        let mut form = blank_form();
25606        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
25607        assert_eq!(
25608            apply(&form).analysis.continuation_line_policy,
25609            ContinuationLinePolicy::CollapseToLogical
25610        );
25611    }
25612
25613    // ── blank_in_block_comment_policy (enum select) ──
25614
25615    #[test]
25616    fn blank_in_block_comment_count_as_comment_when_absent() {
25617        assert_eq!(
25618            apply(&blank_form()).analysis.blank_in_block_comment_policy,
25619            BlankInBlockCommentPolicy::CountAsComment
25620        );
25621    }
25622
25623    #[test]
25624    fn blank_in_block_comment_count_as_blank() {
25625        let mut form = blank_form();
25626        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
25627        assert_eq!(
25628            apply(&form).analysis.blank_in_block_comment_policy,
25629            BlankInBlockCommentPolicy::CountAsBlank
25630        );
25631    }
25632
25633    // ── style_col_threshold ──
25634
25635    #[test]
25636    fn style_threshold_80() {
25637        let mut form = blank_form();
25638        form.style_col_threshold = Some("80".to_string());
25639        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
25640    }
25641
25642    #[test]
25643    fn style_threshold_100() {
25644        let mut form = blank_form();
25645        form.style_col_threshold = Some("100".to_string());
25646        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
25647    }
25648
25649    #[test]
25650    fn style_threshold_120() {
25651        let mut form = blank_form();
25652        form.style_col_threshold = Some("120".to_string());
25653        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
25654    }
25655
25656    #[test]
25657    fn style_threshold_invalid_value_leaves_default() {
25658        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
25659        let mut cfg = sloc_config::AppConfig::default();
25660        let mut form = blank_form();
25661        form.style_col_threshold = Some("42".to_string());
25662        apply_form_to_config(&mut cfg, &form);
25663        assert_eq!(
25664            cfg.analysis.style_col_threshold, 80,
25665            "invalid threshold must not change config"
25666        );
25667    }
25668
25669    #[test]
25670    fn style_threshold_non_numeric_leaves_default() {
25671        let mut cfg = sloc_config::AppConfig::default();
25672        let mut form = blank_form();
25673        form.style_col_threshold = Some("large".to_string());
25674        apply_form_to_config(&mut cfg, &form);
25675        assert_eq!(cfg.analysis.style_col_threshold, 80);
25676    }
25677
25678    #[test]
25679    fn style_threshold_zero_leaves_default() {
25680        let mut cfg = sloc_config::AppConfig::default();
25681        let mut form = blank_form();
25682        form.style_col_threshold = Some("0".to_string());
25683        apply_form_to_config(&mut cfg, &form);
25684        assert_eq!(cfg.analysis.style_col_threshold, 80);
25685    }
25686
25687    #[test]
25688    fn style_threshold_absent_leaves_default() {
25689        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
25690    }
25691
25692    // ── coverage_file ──
25693
25694    #[test]
25695    fn coverage_file_none_when_absent() {
25696        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
25697    }
25698
25699    #[test]
25700    fn coverage_file_none_when_whitespace_only() {
25701        let mut form = blank_form();
25702        form.coverage_file = Some("   ".to_string());
25703        assert!(
25704            apply(&form).analysis.coverage_file.is_none(),
25705            "whitespace-only coverage_file must be treated as None"
25706        );
25707    }
25708
25709    #[test]
25710    fn coverage_file_set_when_non_empty() {
25711        let mut form = blank_form();
25712        form.coverage_file = Some("coverage/lcov.info".to_string());
25713        assert_eq!(
25714            apply(&form).analysis.coverage_file,
25715            Some(std::path::PathBuf::from("coverage/lcov.info"))
25716        );
25717    }
25718
25719    #[test]
25720    fn coverage_file_trims_whitespace() {
25721        let mut form = blank_form();
25722        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
25723        assert_eq!(
25724            apply(&form).analysis.coverage_file,
25725            Some(std::path::PathBuf::from("coverage/lcov.info"))
25726        );
25727    }
25728
25729    // ── report_title ──
25730
25731    #[test]
25732    fn report_title_unchanged_when_absent() {
25733        let original = sloc_config::AppConfig::default()
25734            .reporting
25735            .report_title
25736            .clone();
25737        assert_eq!(apply(&blank_form()).reporting.report_title, original);
25738    }
25739
25740    #[test]
25741    fn report_title_unchanged_when_whitespace_only() {
25742        let original = sloc_config::AppConfig::default()
25743            .reporting
25744            .report_title
25745            .clone();
25746        let mut form = blank_form();
25747        form.report_title = Some("   ".to_string());
25748        assert_eq!(
25749            apply(&form).reporting.report_title,
25750            original,
25751            "whitespace-only title must not overwrite the default"
25752        );
25753    }
25754
25755    #[test]
25756    fn report_title_updated_and_trimmed() {
25757        let mut form = blank_form();
25758        form.report_title = Some("  My Project  ".to_string());
25759        assert_eq!(apply(&form).reporting.report_title, "My Project");
25760    }
25761
25762    // ── report_header_footer ──
25763
25764    #[test]
25765    fn header_footer_none_when_absent() {
25766        assert!(apply(&blank_form())
25767            .reporting
25768            .report_header_footer
25769            .is_none());
25770    }
25771
25772    #[test]
25773    fn header_footer_none_when_whitespace_only() {
25774        let mut form = blank_form();
25775        form.report_header_footer = Some("  ".to_string());
25776        assert!(apply(&form).reporting.report_header_footer.is_none());
25777    }
25778
25779    #[test]
25780    fn header_footer_set_and_trimmed() {
25781        let mut form = blank_form();
25782        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
25783        assert_eq!(
25784            apply(&form).reporting.report_header_footer,
25785            Some("Confidential — Internal Use".to_string())
25786        );
25787    }
25788
25789    // ── include_globs / exclude_globs ──
25790
25791    #[test]
25792    fn include_globs_empty_when_absent() {
25793        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
25794    }
25795
25796    #[test]
25797    fn include_globs_newline_separated() {
25798        let mut form = blank_form();
25799        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
25800        assert_eq!(
25801            apply(&form).discovery.include_globs,
25802            vec!["src/**/*.rs", "tests/**/*.rs"]
25803        );
25804    }
25805
25806    #[test]
25807    fn exclude_globs_comma_separated() {
25808        let mut form = blank_form();
25809        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
25810        assert_eq!(
25811            apply(&form).discovery.exclude_globs,
25812            vec!["vendor/**", "node_modules/**"]
25813        );
25814    }
25815
25816    #[test]
25817    fn globs_mixed_separators() {
25818        let mut form = blank_form();
25819        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
25820        assert_eq!(
25821            apply(&form).discovery.exclude_globs,
25822            vec!["a/**", "b/**", "c/**"]
25823        );
25824    }
25825
25826    // ── split_patterns unit tests ──
25827
25828    #[test]
25829    fn split_patterns_none_is_empty() {
25830        assert!(split_patterns(None).is_empty());
25831    }
25832
25833    #[test]
25834    fn split_patterns_empty_string_is_empty() {
25835        assert!(split_patterns(Some("")).is_empty());
25836    }
25837
25838    #[test]
25839    fn split_patterns_whitespace_only_is_empty() {
25840        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
25841    }
25842
25843    #[test]
25844    fn split_patterns_newlines() {
25845        assert_eq!(
25846            split_patterns(Some("a/**\nb/**\nc/**")),
25847            vec!["a/**", "b/**", "c/**"]
25848        );
25849    }
25850
25851    #[test]
25852    fn split_patterns_commas() {
25853        assert_eq!(
25854            split_patterns(Some("a/**,b/**,c/**")),
25855            vec!["a/**", "b/**", "c/**"]
25856        );
25857    }
25858
25859    #[test]
25860    fn split_patterns_mixed() {
25861        assert_eq!(
25862            split_patterns(Some("a/**\nb/**,c/**")),
25863            vec!["a/**", "b/**", "c/**"]
25864        );
25865    }
25866
25867    #[test]
25868    fn split_patterns_trims_whitespace() {
25869        assert_eq!(
25870            split_patterns(Some("  a/**  \n  b/**  ")),
25871            vec!["a/**", "b/**"]
25872        );
25873    }
25874
25875    #[test]
25876    fn split_patterns_filters_empty_entries() {
25877        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
25878    }
25879
25880    #[test]
25881    fn split_patterns_single_entry() {
25882        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
25883    }
25884}