Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33    collections::{HashMap, VecDeque},
34    fmt::Write,
35    fs,
36    net::{IpAddr, SocketAddr},
37    path::{Path, PathBuf},
38    process::Stdio,
39    sync::{Arc, OnceLock},
40    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46    body::Body,
47    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48    http::{header, HeaderValue, Request, StatusCode},
49    middleware::{self, Next},
50    response::{Html, IntoResponse, Response},
51    routing::{get, post},
52    Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
59    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
60    MixedLinePolicy,
61};
62use sloc_git::ScheduleStore;
63
64#[derive(Clone)]
65pub(crate) struct CspNonce(pub(crate) String);
66
67static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
68static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
69
70use sloc_core::{
71    analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
72    CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
73    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
74};
75use sloc_report::{
76    render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
77    write_pdf_from_run, ReportDeltaContext,
78};
79const MAX_CONCURRENT_ANALYSES: usize = 4;
80
81/// Windows-only helpers that force the native file-picker dialog into the
82/// foreground instead of appearing minimised behind other windows.
83///
84/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
85/// foreground thread so that windows created on our thread inherit focus; and
86/// (b) spin a polling watcher that finds the dialog by title and calls
87/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
88#[cfg(target_os = "windows")]
89#[allow(clippy::upper_case_acronyms)]
90#[allow(dead_code)]
91mod win_dialog_focus {
92    #[cfg(feature = "native-dialog")]
93    use std::mem::size_of;
94
95    type HWND = *mut core::ffi::c_void;
96    type DWORD = u32;
97    type UINT = u32;
98    type BOOL = i32;
99
100    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
101    #[cfg(feature = "native-dialog")]
102    #[repr(C)]
103    #[allow(non_snake_case)]
104    struct FLASHWINFO {
105        cbSize: UINT,
106        hwnd: HWND,
107        dwFlags: DWORD,
108        uCount: UINT,
109        dwTimeout: DWORD,
110    }
111
112    #[cfg(feature = "native-dialog")]
113    const FLASHW_ALL: DWORD = 0x3;
114    #[cfg(feature = "native-dialog")]
115    const FLASHW_TIMERNOFG: DWORD = 0xC;
116
117    #[link(name = "user32")]
118    extern "system" {
119        fn GetForegroundWindow() -> HWND;
120        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
121        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
122        fn BringWindowToTop(hWnd: HWND) -> BOOL;
123        fn SetWindowPos(
124            hWnd: HWND,
125            hWndAfter: HWND,
126            x: i32,
127            y: i32,
128            cx: i32,
129            cy: i32,
130            flags: UINT,
131        ) -> BOOL;
132        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
133        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
134        #[cfg(feature = "native-dialog")]
135        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
136        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
137        fn FindWindowExW(
138            hWndParent: HWND,
139            hWndChildAfter: HWND,
140            lpszClass: *const u16,
141            lpszWindow: *const u16,
142        ) -> HWND;
143        // Undocumented but present on all Windows versions since XP; bypasses
144        // the foreground-lock that blocks SetForegroundWindow from non-foreground
145        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
146        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
147    }
148
149    #[link(name = "kernel32")]
150    extern "system" {
151        #[cfg(feature = "native-dialog")]
152        fn GetCurrentThreadId() -> DWORD;
153    }
154
155    #[link(name = "shell32")]
156    extern "system" {
157        // Opens a folder (or file) via the Windows shell.  Passing the current
158        // foreground window as `hwnd` gives the new window proper activation
159        // context so it surfaces in the foreground without needing
160        // AttachThreadInput or SetForegroundWindow hacks.
161        fn ShellExecuteW(
162            hwnd: HWND,
163            lpOperation: *const u16,
164            lpFile: *const u16,
165            lpParameters: *const u16,
166            lpDirectory: *const u16,
167            nShowCmd: i32,
168        ) -> isize; // HINSTANCE (>32 = success)
169    }
170
171    /// Attaches our thread's input to the foreground window's thread so that
172    /// windows created on our thread inherit foreground focus.  Returns the
173    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
174    /// the thread was already the foreground thread.
175    #[cfg(feature = "native-dialog")]
176    pub fn attach_to_foreground() -> DWORD {
177        unsafe {
178            let fg_hwnd = GetForegroundWindow();
179            if fg_hwnd.is_null() {
180                return 0;
181            }
182            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
183            let my_tid = GetCurrentThreadId();
184            if fg_tid == my_tid {
185                return 0;
186            }
187            AttachThreadInput(my_tid, fg_tid, 1);
188            fg_tid
189        }
190    }
191
192    /// Undoes `attach_to_foreground`.
193    #[cfg(feature = "native-dialog")]
194    pub fn detach_from_foreground(fg_tid: DWORD) {
195        if fg_tid == 0 {
196            return;
197        }
198        unsafe {
199            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
200        }
201    }
202
203    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
204        let mut existing = std::collections::HashSet::new();
205        let mut prev: HWND = core::ptr::null_mut();
206        loop {
207            let w = FindWindowExW(
208                core::ptr::null_mut(),
209                prev,
210                class_w.as_ptr(),
211                core::ptr::null(),
212            );
213            if w.is_null() {
214                break;
215            }
216            existing.insert(w as usize);
217            prev = w;
218        }
219        existing
220    }
221
222    unsafe fn find_new_explorer_hwnd(
223        class_w: &[u16],
224        existing: &std::collections::HashSet<usize>,
225    ) -> Option<HWND> {
226        let mut prev: HWND = core::ptr::null_mut();
227        loop {
228            let w = FindWindowExW(
229                core::ptr::null_mut(),
230                prev,
231                class_w.as_ptr(),
232                core::ptr::null(),
233            );
234            if w.is_null() {
235                return None;
236            }
237            if !existing.contains(&(w as usize)) {
238                return Some(w);
239            }
240            prev = w;
241        }
242    }
243
244    unsafe fn bring_to_front(hwnd: HWND) {
245        // SW_RESTORE = 9 — same sequence as flash_dialog_when_ready.
246        // SwitchToThisWindow bypasses foreground-lock so the window surfaces
247        // regardless of which process currently has focus.
248        ShowWindow(hwnd, 9);
249        SwitchToThisWindow(hwnd, 1);
250        SetForegroundWindow(hwnd);
251        BringWindowToTop(hwnd);
252    }
253
254    /// Opens `path` in Windows Explorer and forces it to the foreground.
255    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
256    /// caller is not the foreground process (the browser is).  After launching,
257    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
258    /// an undocumented API that bypasses Windows' foreground-lock restriction
259    /// so the window surfaces regardless of which process currently has focus.
260    pub fn open_folder_foreground(path: std::path::PathBuf) {
261        std::thread::spawn(move || {
262            use std::os::windows::ffi::OsStrExt;
263
264            let op: Vec<u16> = "explore\0".encode_utf16().collect();
265            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
266            path_w.push(0);
267            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
268
269            unsafe {
270                // Snapshot every existing Explorer window before we launch so
271                // we can identify the newly created one.
272                let existing = snapshot_explorer_hwnds(&class_w);
273                let fg_hwnd = GetForegroundWindow();
274                // SW_SHOWNORMAL = 1
275                ShellExecuteW(
276                    fg_hwnd,
277                    op.as_ptr(),
278                    path_w.as_ptr(),
279                    core::ptr::null(),
280                    core::ptr::null(),
281                    1,
282                );
283
284                // Poll up to ~3 s for a new CabinetWClass window to appear,
285                // then use SwitchToThisWindow (bypasses foreground-lock) to
286                // bring it in front of the browser and everything else.
287                for _ in 0..40 {
288                    std::thread::sleep(std::time::Duration::from_millis(75));
289                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
290                        bring_to_front(w);
291                        return;
292                    }
293                }
294
295                // Fallback: Explorer reused an existing window — bring whichever
296                // CabinetWClass window is first in Z-order to the front.
297                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
298                if !w.is_null() {
299                    bring_to_front(w);
300                }
301            }
302        });
303    }
304
305    /// Spawns a short-lived watcher thread that polls for a dialog window
306    /// matching `title` and, once found, forces it to the foreground and
307    /// flashes its taskbar button until the user interacts with it.
308    #[cfg(feature = "native-dialog")]
309    pub fn flash_dialog_when_ready(title: String) {
310        std::thread::spawn(move || {
311            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
312            for _ in 0..40 {
313                std::thread::sleep(std::time::Duration::from_millis(80));
314                unsafe {
315                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
316                    if !hwnd.is_null() {
317                        SetForegroundWindow(hwnd);
318                        BringWindowToTop(hwnd);
319                        #[allow(non_snake_case)]
320                        FlashWindowEx(&FLASHWINFO {
321                            // size_of returns usize; Win32 struct field is u32 (UINT).
322                            // struct size fits trivially within u32.
323                            #[allow(clippy::cast_possible_truncation)]
324                            cbSize: size_of::<FLASHWINFO>() as UINT,
325                            hwnd,
326                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
327                            uCount: 3,
328                            dwTimeout: 0,
329                        });
330                        break;
331                    }
332                }
333            }
334        });
335    }
336}
337
338/// Sliding-window rate limiter keyed by client IP.
339/// Uses only std primitives — no external crate required.
340pub(crate) struct IpRateLimiter {
341    window: Duration,
342    max_requests: usize,
343    pub(crate) auth_lockout_threshold: u32,
344    auth_lockout_window: Duration,
345    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
346    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
347}
348
349impl IpRateLimiter {
350    pub(crate) fn new(
351        window: Duration,
352        max_requests: usize,
353        auth_lockout_threshold: u32,
354        auth_lockout_window: Duration,
355    ) -> Self {
356        Self {
357            window,
358            max_requests,
359            auth_lockout_threshold,
360            auth_lockout_window,
361            state: std::sync::Mutex::new(HashMap::new()),
362            auth_failures: std::sync::Mutex::new(HashMap::new()),
363        }
364    }
365
366    // The MutexGuard `state` must live as long as `bucket` borrows from it,
367    // so it cannot be dropped any earlier than the end of the inner block.
368    #[allow(clippy::significant_drop_tightening)]
369    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
370        let now = Instant::now();
371        let cutoff = now.checked_sub(self.window).unwrap_or(now);
372        let mut state = self
373            .state
374            .lock()
375            .unwrap_or_else(std::sync::PoisonError::into_inner);
376        if state.len() > 10_000 {
377            state.retain(|_, bucket| {
378                while bucket.front().is_some_and(|t| *t <= cutoff) {
379                    bucket.pop_front();
380                }
381                !bucket.is_empty()
382            });
383        }
384        let bucket = state.entry(ip).or_default();
385        while bucket.front().is_some_and(|t| *t <= cutoff) {
386            bucket.pop_front();
387        }
388        if bucket.len() >= self.max_requests {
389            false
390        } else {
391            bucket.push_back(now);
392            true
393        }
394    }
395
396    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
397        let now = Instant::now();
398        let mut map = self
399            .auth_failures
400            .lock()
401            .unwrap_or_else(std::sync::PoisonError::into_inner);
402        map.entry(ip)
403            .and_modify(|e| {
404                e.0 += 1;
405                e.1 = now;
406            })
407            .or_insert_with(|| (1, now));
408    }
409
410    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
411        let mut map = self
412            .auth_failures
413            .lock()
414            .unwrap_or_else(std::sync::PoisonError::into_inner);
415        let expired = map
416            .get(&ip)
417            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
418        if expired {
419            map.remove(&ip);
420            return false;
421        }
422        map.get(&ip)
423            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
424    }
425
426    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
427        let map = self
428            .auth_failures
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        map.get(&ip).map_or(0, |e| {
432            self.auth_lockout_window
433                .checked_sub(e.1.elapsed())
434                .map_or(0, |r| r.as_secs())
435        })
436    }
437
438    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
439        tokio::spawn(async move {
440            let mut interval = tokio::time::interval(Duration::from_mins(1));
441            interval.tick().await; // consume the immediate first tick
442            loop {
443                interval.tick().await;
444                let now = Instant::now();
445                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
446                {
447                    let mut state = limiter
448                        .state
449                        .lock()
450                        .unwrap_or_else(std::sync::PoisonError::into_inner);
451                    state.retain(|_, bucket| {
452                        while bucket.front().is_some_and(|t| *t <= cutoff) {
453                            bucket.pop_front();
454                        }
455                        !bucket.is_empty()
456                    });
457                }
458                {
459                    let mut auth = limiter
460                        .auth_failures
461                        .lock()
462                        .unwrap_or_else(std::sync::PoisonError::into_inner);
463                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
464                }
465            }
466        });
467    }
468}
469
470/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
471/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
472/// files but never triggers a scan.
473fn spawn_upload_staging_cleanup() {
474    tokio::spawn(async move {
475        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
476            .ok()
477            .and_then(|v| v.parse().ok())
478            .unwrap_or(4);
479        let ttl_secs = ttl_hours * 3600;
480        let mut interval = tokio::time::interval(Duration::from_hours(1));
481        interval.tick().await; // consume the immediate first tick
482        loop {
483            interval.tick().await;
484            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
485            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
486                continue;
487            };
488            while let Ok(Some(entry)) = dir.next_entry().await {
489                let path = entry.path();
490                let age_secs = tokio::fs::metadata(&path)
491                    .await
492                    .ok()
493                    .and_then(|m| m.modified().ok())
494                    .and_then(|t| t.elapsed().ok())
495                    .map_or(0, |d| d.as_secs());
496                if age_secs > ttl_secs {
497                    tracing::debug!(
498                        event = "upload_staging_cleanup",
499                        path = %path.display(),
500                        age_secs,
501                        "removing stale upload staging directory"
502                    );
503                    let _ = tokio::fs::remove_dir_all(&path).await;
504                }
505            }
506        }
507    });
508}
509
510/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
511#[derive(Clone, Debug, Default)]
512struct RunResultContext {
513    prev_entry: Option<RegistryEntry>,
514    prev_scan_count: usize,
515    project_path: String,
516    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
517    cocomo_mode: String,
518    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
519    complexity_alert: u32,
520    /// Whether duplicate files should be excluded from displayed SLOC totals.
521    #[allow(dead_code)]
522    exclude_duplicates: bool,
523}
524
525/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
526#[derive(Clone)]
527enum AsyncRunState {
528    Running {
529        started_at: std::time::Instant,
530        cancel_token: Arc<std::sync::atomic::AtomicBool>,
531        phase: Arc<std::sync::Mutex<String>>,
532        files_done: Arc<std::sync::atomic::AtomicUsize>,
533        files_total: Arc<std::sync::atomic::AtomicUsize>,
534    },
535    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
536    Complete {
537        run_id: String,
538    },
539    Failed {
540        message: String,
541    },
542    Cancelled,
543}
544
545/// A saved scan configuration profile — stores the form parameters so users can
546/// re-run a favourite scan with one click.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548struct ScanProfile {
549    id: String,
550    name: String,
551    created_at: String,
552    /// The raw scan-form parameters serialized as JSON.
553    params: serde_json::Value,
554}
555
556#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557struct ScanProfileStore {
558    profiles: Vec<ScanProfile>,
559}
560
561impl ScanProfileStore {
562    fn load(path: &std::path::Path) -> Self {
563        fs::read_to_string(path)
564            .ok()
565            .and_then(|s| serde_json::from_str(&s).ok())
566            .unwrap_or_default()
567    }
568
569    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
570        if let Some(parent) = path.parent() {
571            fs::create_dir_all(parent)?;
572        }
573        let json = serde_json::to_string_pretty(self)?;
574        fs::write(path, json)?;
575        Ok(())
576    }
577}
578
579#[derive(Clone)]
580pub(crate) struct AppState {
581    pub(crate) base_config: AppConfig,
582    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
583    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
584    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
585    pub(crate) registry_path: PathBuf,
586    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
587    pub(crate) server_mode: bool,
588    pub(crate) tls_enabled: bool,
589    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
590    pub(crate) rate_limiter: Arc<IpRateLimiter>,
591    pub(crate) trust_proxy: bool,
592    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
593    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
594    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
595    /// Directory where remote repositories are cloned for git-browser scans.
596    pub(crate) git_clones_dir: PathBuf,
597    /// Persisted list of webhook / poll schedules.
598    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
599    pub(crate) schedules_path: PathBuf,
600    /// Named scan profiles saved by the user via the web UI.
601    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
602    pub(crate) scan_profiles_path: PathBuf,
603    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
604    /// Persisted Confluence integration settings.
605    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
606    pub(crate) confluence_path: PathBuf,
607    /// Directories the user has pinned for auto-scanning of external reports.
608    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
609    pub(crate) watched_dirs_path: PathBuf,
610    /// Persisted auto-cleanup policy (age/count limits + interval).
611    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
612    pub(crate) cleanup_policy_path: PathBuf,
613    /// Handle for the running cleanup background task; replaced on policy change.
614    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
615}
616
617type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
618
619/// Parameters for the fire-and-forget HTML + PDF background task.
620
621#[derive(Clone, Debug)]
622pub(crate) struct RunArtifacts {
623    output_dir: PathBuf,
624    html_path: Option<PathBuf>,
625    pdf_path: Option<PathBuf>,
626    json_path: Option<PathBuf>,
627    csv_path: Option<PathBuf>,
628    xlsx_path: Option<PathBuf>,
629    scan_config_path: Option<PathBuf>,
630    report_title: String,
631    result_context: RunResultContext,
632}
633
634#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
635fn build_router(state: AppState) -> Router {
636    let protected = Router::new()
637        .route("/", get(splash))
638        .route("/scan-setup", get(scan_setup_handler))
639        .route("/scan", get(index))
640        .route("/analyze", post(analyze_handler))
641        .route("/preview", get(preview_handler))
642        .route("/api/suggest-coverage", get(api_suggest_coverage))
643        .route("/pick-directory", get(pick_directory_handler))
644        .route("/open-path", get(open_path_handler))
645        .route("/pick-file", get(pick_file_handler))
646        .route(
647            "/api/upload-directory",
648            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
649        )
650        .route(
651            "/api/upload-file",
652            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
653        )
654        .route(
655            "/api/upload-tarball",
656            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
657            // The handler also enforces this limit during streaming so both layers agree.
658            post(upload_tarball_handler)
659                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
660        )
661        .route("/locate-report", post(locate_report_handler))
662        .route("/locate-reports-dir", post(locate_reports_dir_handler))
663        .route("/relocate-scan", post(relocate_scan_handler))
664        .route("/watched-dirs/add", post(add_watched_dir_handler))
665        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
666        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
667        .route("/view-reports", get(history_handler))
668        .route("/compare-scans", get(compare_select_handler))
669        .route("/compare", get(compare_handler))
670        .route("/multi-compare", get(multi_compare_handler))
671        .route("/images/{folder}/{file}", get(image_handler))
672        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
673        .route("/api/metrics/latest", get(api_metrics_latest_handler))
674        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
675        .route("/api/metrics/history", get(api_metrics_history_handler))
676        .route("/api/metrics/churn", get(api_metrics_churn_handler))
677        .route(
678            "/api/metrics/submodules",
679            get(api_metrics_submodules_handler),
680        )
681        .route("/api/ingest", post(api_ingest_handler))
682        .route("/api/project-history", get(project_history_handler))
683        .route("/trend-reports", get(trend_report_handler))
684        .route("/test-metrics", get(test_metrics_handler))
685        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
686        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
687        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
688        .route("/runs/result/{run_id}", get(async_run_result_handler))
689        .route("/embed/summary", get(embed_handler))
690        // ── Git browser ────────────────────────────────────────────────────────
691        .route("/git-browser", get(git_browser::git_browser_handler))
692        .route("/api/git/refs", get(git_browser::api_list_refs))
693        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
694        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
695        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
696        // The request body is the full rendered HTML report, whose size scales
697        // with file count — large repos (Compare Scans, Files, Trend, Test
698        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
699        .route(
700            "/export/pdf",
701            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
702        )
703        // ── Config export / import ─────────────────────────────────────────────
704        .route("/export-config", get(export_config_handler))
705        .route("/import-config", post(import_config_handler))
706        // ── Scan profiles ──────────────────────────────────────────────────────
707        .route("/api/scan-profiles", get(api_list_scan_profiles))
708        .route("/api/scan-profiles", post(api_save_scan_profile))
709        .route(
710            "/api/scan-profiles/{id}",
711            axum::routing::delete(api_delete_scan_profile),
712        )
713        // ── Integrations (webhooks + Confluence) ──────────────────────────────
714        .route("/integrations", get(integrations::integrations_handler))
715        .route(
716            "/webhook-setup",
717            get(|| async { axum::response::Redirect::permanent("/integrations") }),
718        )
719        .route(
720            "/confluence-setup",
721            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
722        )
723        .route("/api/schedules", get(git_webhook::api_list_schedules))
724        .route("/api/schedules", post(git_webhook::api_create_schedule))
725        .route(
726            "/api/schedules",
727            axum::routing::delete(git_webhook::api_delete_schedule),
728        )
729        .route(
730            "/api/confluence/config",
731            get(confluence::api_get_confluence_config),
732        )
733        .route(
734            "/api/confluence/config",
735            post(confluence::api_save_confluence_config),
736        )
737        .route(
738            "/api/confluence/test",
739            post(confluence::api_test_confluence),
740        )
741        .route(
742            "/api/confluence/post",
743            post(confluence::api_post_to_confluence),
744        )
745        .route(
746            "/api/confluence/wiki-markup",
747            get(confluence::api_wiki_markup),
748        )
749        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
750        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
751        .route(
752            "/api/runs/{run_id}",
753            axum::routing::delete(delete_run_handler),
754        )
755        .route("/api/runs/cleanup", post(cleanup_runs_handler))
756        // ── Auto-cleanup policy ────────────────────────────────────────────────
757        .route(
758            "/api/cleanup-policy",
759            get(api_get_cleanup_policy)
760                .post(api_save_cleanup_policy)
761                .delete(api_delete_cleanup_policy),
762        )
763        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
764        // ── REST API reference page ────────────────────────────────────────────
765        .route("/api-docs", get(api_docs_handler))
766        // ── Prometheus metrics — behind API-key auth ───────────────────────────
767        .route("/metrics", get(metrics_handler))
768        .route_layer(middleware::from_fn_with_state(
769            state.clone(),
770            auth::require_api_key,
771        ));
772
773    protected
774        .route("/healthz", get(healthz))
775        .route("/api/health", get(healthz))
776        .route("/api/version", get(api_version_handler))
777        .route("/api/openapi.yaml", get(openapi_yaml_handler))
778        .route("/llms.txt", get(llms_txt_handler))
779        .route("/llms-full.txt", get(llms_full_txt_handler))
780        .route("/badge/{metric}", get(badge_handler))
781        .route("/static/chart.js", get(chart_js_handler))
782        .route("/static/chart-report.js", get(report_chart_js_handler))
783        .route("/auth/login", get(auth::auth_login_get))
784        .route("/auth/login", post(auth::auth_login_post))
785        .route("/auth/logout", post(auth::auth_logout))
786        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
787        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
788        .route(
789            "/webhooks/github",
790            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
791        )
792        .route(
793            "/webhooks/gitlab",
794            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
795        )
796        .route(
797            "/webhooks/bitbucket",
798            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
799        )
800        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
801        .layer(middleware::from_fn(csrf_protect))
802        .layer(middleware::from_fn_with_state(
803            state.clone(),
804            add_security_headers,
805        ))
806        .layer(build_cors_layer(state.server_mode))
807        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
808        .with_state(state)
809}
810
811/// Bearer token used by `make_test_router_server_mode()` test routers.
812/// Tests that exercise server-mode paths must include this key in their requests.
813pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
814
815/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
816/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
817/// builders below start from this and override only the fields they care about.
818///
819/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
820fn test_app_state(tmp_subdir: &str) -> AppState {
821    std::env::set_var("SLOC_HEADLESS", "1");
822    let tmp = std::env::temp_dir().join(tmp_subdir);
823    AppState {
824        base_config: AppConfig::default(),
825        artifacts: Arc::new(Mutex::new(HashMap::new())),
826        async_runs: Arc::new(Mutex::new(HashMap::new())),
827        registry: Arc::new(Mutex::new(ScanRegistry::default())),
828        registry_path: tmp.join("registry.json"),
829        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
830        server_mode: false,
831        tls_enabled: false,
832        api_keys: Arc::new(vec![]),
833        rate_limiter: Arc::new(IpRateLimiter::new(
834            Duration::from_mins(1),
835            600,
836            10,
837            Duration::from_hours(1),
838        )),
839        trust_proxy: false,
840        trusted_proxy_ips: vec![],
841        git_clones_dir: tmp.join("git-clones"),
842        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
843        schedules_path: tmp.join("schedules.json"),
844        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
845        scan_profiles_path: tmp.join("scan_profiles.json"),
846        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
847        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
848        confluence_path: tmp.join("confluence_config.json"),
849        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
850        watched_dirs_path: tmp.join("watched_dirs.json"),
851        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
852        cleanup_policy_path: tmp.join("cleanup_policy.json"),
853        cleanup_task_handle: Arc::new(Mutex::new(None)),
854    }
855}
856
857/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
858pub fn make_test_router() -> Router {
859    build_router(test_app_state("sloc_test"))
860}
861
862/// Test router with one API key pre-loaded. Used by auth integration tests.
863pub fn make_test_router_with_key(api_key: &str) -> Router {
864    let mut state = test_app_state("sloc_test_key");
865    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
866    build_router(state)
867}
868
869/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
870/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
871/// preview restrictions.
872pub fn make_test_router_server_mode() -> Router {
873    let mut state = test_app_state("sloc_test_server");
874    state.server_mode = true;
875    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
876        TEST_SERVER_MODE_API_KEY.to_owned(),
877    ))]);
878    build_router(state)
879}
880
881/// Server-mode test router with `allowed_scan_roots` configured. Exercises the
882/// `validate_server_scan_path` allow/deny branches (in-root success, unresolved
883/// path, and out-of-root rejection) that the empty-roots router cannot reach.
884pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
885    let mut state = test_app_state("sloc_test_server_roots");
886    state.server_mode = true;
887    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
888        TEST_SERVER_MODE_API_KEY.to_owned(),
889    ))]);
890    state.base_config.discovery.allowed_scan_roots = roots;
891    build_router(state)
892}
893
894/// Test router where the analysis semaphore is pre-exhausted (0 permits).
895/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
896pub fn make_test_router_exhausted_semaphore() -> Router {
897    let mut state = test_app_state("sloc_test_exhaust");
898    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
899    build_router(state)
900}
901
902/// Test router with a very tight rate limit (3 req/min). The third request from
903/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
904pub fn make_test_router_tight_rate_limit() -> Router {
905    let mut state = test_app_state("sloc_test_rate");
906    state.rate_limiter = Arc::new(IpRateLimiter::new(
907        Duration::from_mins(1),
908        2,
909        5,
910        Duration::from_secs(5),
911    ));
912    build_router(state)
913}
914
915/// Test router with a very tight auth lockout (threshold=2, window=200ms).
916/// Used by tests that need to trigger and verify the auth lockout response.
917pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
918    let mut state = test_app_state("sloc_test_auth_lockout");
919    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
920    state.rate_limiter = Arc::new(IpRateLimiter::new(
921        Duration::from_mins(1),
922        600,
923        2,                          // 2 failures triggers lockout
924        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
925    ));
926    build_router(state)
927}
928
929struct RuntimeSecurityConfig {
930    api_keys: Vec<secrecy::SecretBox<String>>,
931    tls_cert: Option<String>,
932    tls_key: Option<String>,
933    tls_enabled: bool,
934    trust_proxy: bool,
935    trusted_proxy_ips: Vec<IpAddr>,
936    rate_limiter: Arc<IpRateLimiter>,
937}
938
939fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
940    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
941        .or_else(|_| std::env::var("SLOC_API_KEY"))
942        .unwrap_or_default()
943        .split(',')
944        .map(str::trim)
945        .filter(|s| !s.is_empty())
946        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
947        .collect();
948    if server_mode && api_keys.is_empty() {
949        println!(
950            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
951             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
952        );
953    }
954    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
955    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
956    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
957    if server_mode && !tls_enabled {
958        println!(
959            "WARNING: TLS is not configured. Traffic is cleartext. \
960             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
961             or terminate TLS at a reverse proxy (nginx, caddy)."
962        );
963    }
964    if server_mode {
965        println!(
966            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
967             to restrict cross-origin access (comma-separated)."
968        );
969    }
970    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
971    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
972        .unwrap_or_default()
973        .split(',')
974        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
975        .collect();
976    if trust_proxy {
977        if trusted_proxy_ips.is_empty() {
978            println!(
979                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
980                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
981                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
982            );
983        } else {
984            println!(
985                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
986                trusted_proxy_ips
987                    .iter()
988                    .map(std::string::ToString::to_string)
989                    .collect::<Vec<_>>()
990                    .join(", ")
991            );
992        }
993    } else if server_mode {
994        println!(
995            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
996             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
997             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
998             enable per-client rate limiting via X-Forwarded-For."
999        );
1000    }
1001    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1002        println!(
1003            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1004             DISABLED for all git operations. Remove this variable before production use."
1005        );
1006    }
1007    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1008        .ok()
1009        .and_then(|v| v.parse::<u32>().ok())
1010        .unwrap_or(10);
1011    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1012        .ok()
1013        .and_then(|v| v.parse::<u64>().ok())
1014        .unwrap_or(3600);
1015    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1016    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1017    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1018    let default_rpm: usize = if server_mode { 120 } else { 600 };
1019    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1020        .ok()
1021        .and_then(|v| v.parse::<usize>().ok())
1022        .unwrap_or(default_rpm);
1023    let rate_limiter = Arc::new(IpRateLimiter::new(
1024        Duration::from_mins(1),
1025        rate_limit_rpm,
1026        auth_lockout_threshold,
1027        Duration::from_secs(auth_lockout_secs),
1028    ));
1029    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1030    RuntimeSecurityConfig {
1031        api_keys,
1032        tls_cert,
1033        tls_key,
1034        tls_enabled,
1035        trust_proxy,
1036        trusted_proxy_ips,
1037        rate_limiter,
1038    }
1039}
1040
1041/// # Errors
1042///
1043/// Returns an error if the server fails to bind to the configured address or
1044/// if the TLS configuration cannot be loaded.
1045///
1046/// # Panics
1047///
1048/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1049#[allow(clippy::too_many_lines)]
1050pub async fn serve(config: AppConfig) -> Result<()> {
1051    let bind_address = config.web.bind_address.clone();
1052    let server_mode = config.web.server_mode;
1053    let output_root = resolve_output_root(None);
1054    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1055    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1056        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1057    let mut registry = ScanRegistry::load(&registry_path);
1058    registry.prune_stale();
1059    let _ = registry.save(&registry_path);
1060
1061    let sec = load_runtime_security_config(server_mode);
1062    spawn_upload_staging_cleanup();
1063
1064    let git_clones_dir = resolve_git_clones_dir(&output_root);
1065    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1066        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1067    let schedules = ScheduleStore::load(&schedules_path);
1068    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1069        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1070    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1071    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1072        |_| output_root.join("confluence_config.json"),
1073        PathBuf::from,
1074    );
1075    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1076    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1077        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1078    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1079    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1080        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1081    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1082
1083    let state = AppState {
1084        base_config: config,
1085        artifacts: Arc::new(Mutex::new(HashMap::new())),
1086        async_runs: Arc::new(Mutex::new(HashMap::new())),
1087        registry: Arc::new(Mutex::new(registry)),
1088        registry_path,
1089        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1090        server_mode,
1091        tls_enabled: sec.tls_enabled,
1092        api_keys: Arc::new(sec.api_keys),
1093        rate_limiter: sec.rate_limiter,
1094        trust_proxy: sec.trust_proxy,
1095        trusted_proxy_ips: sec.trusted_proxy_ips,
1096        git_clones_dir,
1097        schedules: Arc::new(Mutex::new(schedules)),
1098        schedules_path,
1099        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1100        scan_profiles_path,
1101        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1102        confluence: Arc::new(Mutex::new(confluence)),
1103        confluence_path,
1104        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1105        watched_dirs_path,
1106        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1107        cleanup_policy_path,
1108        cleanup_task_handle: Arc::new(Mutex::new(None)),
1109    };
1110
1111    restart_poll_schedules(&state).await;
1112    warn_insecure_gitlab_webhooks(&state).await;
1113
1114    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1115    {
1116        let enabled = state
1117            .cleanup_policy
1118            .lock()
1119            .await
1120            .policy
1121            .as_ref()
1122            .is_some_and(|p| p.enabled);
1123        if enabled {
1124            let handle = spawn_cleanup_policy_task(state.clone());
1125            *state.cleanup_task_handle.lock().await = Some(handle);
1126        }
1127    }
1128
1129    let app = build_router(state.clone());
1130
1131    // Try the configured port first, then step up through a few alternatives.
1132    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1133    // kernel zombie (visible in netstat but owned by no living process).  Rather
1134    // than failing, we auto-select the next free port and tell the user.
1135    let preferred: SocketAddr = bind_address
1136        .parse()
1137        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1138    let (listener, addr) = {
1139        let candidates = (0u16..=9).map(|offset| {
1140            let mut a = preferred;
1141            a.set_port(preferred.port().saturating_add(offset));
1142            a
1143        });
1144        let mut found = None;
1145        for candidate in candidates {
1146            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1147                found = Some((l, candidate));
1148                break;
1149            }
1150        }
1151        found.ok_or_else(|| {
1152            anyhow::anyhow!(
1153                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1154                bind_address,
1155                preferred.port(),
1156                preferred.port().saturating_add(9)
1157            )
1158        })?
1159    };
1160    if addr != preferred {
1161        eprintln!(
1162            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1163             using {} instead.",
1164            preferred.port(),
1165            addr.port()
1166        );
1167    }
1168
1169    if sec.tls_enabled {
1170        let cert_path = sec
1171            .tls_cert
1172            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1173        let key_path = sec
1174            .tls_key
1175            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1176        let tls_config = build_tls_config(&cert_path, &key_path)
1177            .context("failed to load TLS certificate/key")?;
1178        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1179
1180        let url = format!("https://{addr}/");
1181        println!("OxideSLOC server running at {url} (TLS)");
1182        println!("Use Ctrl+C to stop.");
1183
1184        return serve_tls(listener, app, acceptor, server_mode).await;
1185    }
1186
1187    let url = format!("http://{addr}/");
1188    log_startup_url(&url, server_mode);
1189
1190    axum::serve(
1191        listener,
1192        app.into_make_service_with_connect_info::<SocketAddr>(),
1193    )
1194    .with_graceful_shutdown(shutdown_signal(server_mode))
1195    .await
1196    .context("web server terminated unexpectedly")
1197}
1198
1199/// Discover the primary non-loopback IPv4 address by asking the OS which
1200/// outbound interface it would use to reach a public address.  No packets are
1201/// sent — the UDP socket is only used to query the routing table.
1202fn primary_lan_ip() -> Option<String> {
1203    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1204    socket.connect("8.8.8.8:80").ok()?;
1205    let addr = socket.local_addr().ok()?;
1206    let ip = addr.ip();
1207    if ip.is_loopback() {
1208        return None;
1209    }
1210    Some(ip.to_string())
1211}
1212
1213/// Print the startup URL and, in local mode, open the browser and schedule it.
1214fn log_startup_url(url: &str, server_mode: bool) {
1215    if server_mode {
1216        println!("OxideSLOC server running at {url}");
1217        println!("Use Ctrl+C to stop.");
1218    } else {
1219        println!("OxideSLOC local web UI running at {url}");
1220        println!("Press Ctrl+C to stop the server.");
1221        let open_url = url.to_owned();
1222        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1223    }
1224}
1225
1226/// Open the given URL in the default system browser.
1227fn open_browser_tab(url: &str) {
1228    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1229    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1230    // first quoted token as a window title — both are fragile and shell-parsed. The
1231    // url.dll handler receives the URL as a single, non-shell argument.
1232    #[cfg(target_os = "windows")]
1233    let _ = std::process::Command::new("rundll32")
1234        .args(["url.dll,FileProtocolHandler", url])
1235        .stdout(Stdio::null())
1236        .stderr(Stdio::null())
1237        .spawn();
1238    #[cfg(target_os = "macos")]
1239    let _ = std::process::Command::new("open")
1240        .arg(url)
1241        .stdout(Stdio::null())
1242        .stderr(Stdio::null())
1243        .spawn();
1244    #[cfg(target_os = "linux")]
1245    let _ = std::process::Command::new("xdg-open")
1246        .arg(url)
1247        .stdout(Stdio::null())
1248        .stderr(Stdio::null())
1249        .spawn();
1250}
1251
1252/// Graceful-shutdown future: resolves on Ctrl-C.
1253async fn shutdown_signal(server_mode: bool) {
1254    if tokio::signal::ctrl_c().await.is_ok() {
1255        println!();
1256        if server_mode {
1257            println!("Shutting down OxideSLOC server...");
1258        } else {
1259            println!("Shutting down OxideSLOC local web UI...");
1260        }
1261        println!("Server stopped cleanly.");
1262    }
1263}
1264
1265/// Load a rustls `ServerConfig` from PEM certificate and key files.
1266fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1267    use rustls_pki_types::pem::PemObject;
1268    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1269
1270    let cert_bytes =
1271        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1272    let key_bytes =
1273        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1274
1275    let cert_chain: Vec<CertificateDer<'static>> =
1276        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1277            .collect::<std::result::Result<_, _>>()
1278            .context("failed to parse TLS certificates")?;
1279
1280    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1281        .context("failed to parse TLS private key")?;
1282
1283    rustls::ServerConfig::builder()
1284        .with_no_client_auth()
1285        .with_single_cert(cert_chain, key)
1286        .context("failed to build TLS server config")
1287}
1288
1289/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1290async fn serve_tls(
1291    listener: tokio::net::TcpListener,
1292    app: Router,
1293    acceptor: tokio_rustls::TlsAcceptor,
1294    server_mode: bool,
1295) -> Result<()> {
1296    use hyper_util::rt::{TokioExecutor, TokioIo};
1297    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1298    use hyper_util::service::TowerToHyperService;
1299    use tower::{Service, ServiceExt};
1300
1301    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1302
1303    loop {
1304        tokio::select! {
1305            biased;
1306            _ = tokio::signal::ctrl_c() => {
1307                println!();
1308                if server_mode {
1309                    println!("Shutting down OxideSLOC server...");
1310                } else {
1311                    println!("Shutting down OxideSLOC local web UI...");
1312                }
1313                println!("Server stopped cleanly.");
1314                return Ok(());
1315            }
1316            result = listener.accept() => {
1317                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1318                let acceptor = acceptor.clone();
1319                let mut factory = make_svc.clone();
1320
1321                tokio::spawn(async move {
1322                    let tls = match acceptor.accept(tcp).await {
1323                        Ok(s) => s,
1324                        Err(e) => {
1325                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1326                            return;
1327                        }
1328                    };
1329                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1330                        Ok(f) => match Service::call(f, peer_addr).await {
1331                            Ok(s) => s,
1332                            Err(_) => return,
1333                        },
1334                        Err(_) => return,
1335                    };
1336                    let io = TokioIo::new(tls);
1337                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1338                        .serve_connection(io, TowerToHyperService::new(svc))
1339                        .await
1340                    {
1341                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1342                    }
1343                });
1344            }
1345        }
1346    }
1347}
1348
1349// auth moved to auth.rs
1350
1351fn build_cors_layer(server_mode: bool) -> CorsLayer {
1352    if server_mode {
1353        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1354            .unwrap_or_default()
1355            .split(',')
1356            .filter(|s| !s.is_empty())
1357            .filter_map(|s| s.trim().parse().ok())
1358            .collect();
1359        if allowed.is_empty() {
1360            return CorsLayer::new();
1361        }
1362        CorsLayer::new()
1363            .allow_origin(AllowOrigin::list(allowed))
1364            .allow_methods(AllowMethods::list([
1365                axum::http::Method::GET,
1366                axum::http::Method::POST,
1367            ]))
1368            .allow_headers(AllowHeaders::list([
1369                axum::http::header::AUTHORIZATION,
1370                axum::http::header::CONTENT_TYPE,
1371            ]))
1372    } else {
1373        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1374            let s = origin.to_str().unwrap_or("");
1375            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1376        }))
1377    }
1378}
1379
1380async fn add_security_headers(
1381    State(state): State<AppState>,
1382    mut req: Request<Body>,
1383    next: Next,
1384) -> Response {
1385    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1386    req.extensions_mut().insert(CspNonce(nonce.clone()));
1387    let mut resp = next.run(req).await;
1388    inject_page_fade_into_html(&mut resp, &nonce).await;
1389    let h = resp.headers_mut();
1390    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1391    h.insert(
1392        "X-Content-Type-Options",
1393        HeaderValue::from_static("nosniff"),
1394    );
1395    h.insert(
1396        "Referrer-Policy",
1397        HeaderValue::from_static("strict-origin-when-cross-origin"),
1398    );
1399    let csp = format!(
1400        "default-src 'self'; \
1401         base-uri 'self'; \
1402         form-action 'self'; \
1403         style-src 'self' 'unsafe-inline'; \
1404         img-src 'self' data: blob:; \
1405         script-src 'self' 'nonce-{nonce}'; \
1406         font-src 'self' data:; \
1407         object-src 'none'; \
1408         frame-ancestors 'none'"
1409    );
1410    h.insert(
1411        "Content-Security-Policy",
1412        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1413            HeaderValue::from_static(
1414                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1415            )
1416        }),
1417    );
1418    h.insert(
1419        "X-Permitted-Cross-Domain-Policies",
1420        HeaderValue::from_static("none"),
1421    );
1422    h.insert(
1423        "Permissions-Policy",
1424        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1425    );
1426    h.insert(
1427        "Cross-Origin-Opener-Policy",
1428        HeaderValue::from_static("same-origin"),
1429    );
1430    h.insert(
1431        "Cross-Origin-Resource-Policy",
1432        HeaderValue::from_static("same-origin"),
1433    );
1434    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1435    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1436    h.insert(
1437        "Cross-Origin-Embedder-Policy",
1438        HeaderValue::from_static("require-corp"),
1439    );
1440    if state.tls_enabled {
1441        h.insert(
1442            "Strict-Transport-Security",
1443            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1444        );
1445    }
1446    resp
1447}
1448
1449/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1450///
1451/// On state-changing methods, browser-driven cookie-authenticated requests must
1452/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1453/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1454///
1455/// Deliberately exempt:
1456/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1457/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1458///   ambient, so it is not CSRF-exploitable.
1459/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1460/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1461///   a browser performing a CSRF attack always sends `Origin`.
1462async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1463    use axum::http::Method;
1464
1465    let is_state_changing = matches!(
1466        *req.method(),
1467        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1468    );
1469    let path = req.uri().path();
1470    let has_token_auth = req.headers().contains_key("X-API-Key")
1471        || req
1472            .headers()
1473            .get(header::AUTHORIZATION)
1474            .and_then(|v| v.to_str().ok())
1475            .is_some_and(|v| v.starts_with("Bearer "));
1476
1477    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1478        return next.run(req).await;
1479    }
1480
1481    let headers = req.headers();
1482    let header_str = |name: &header::HeaderName| {
1483        headers
1484            .get(name)
1485            .and_then(|v| v.to_str().ok())
1486            .map(str::to_owned)
1487    };
1488    let origin = header_str(&header::ORIGIN);
1489    let referer = header_str(&header::REFERER);
1490    let host = header_str(&header::HOST);
1491
1492    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1493    let authority_of = |url: &str| -> Option<String> {
1494        url.split_once("://")
1495            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1496    };
1497
1498    let source_authority = origin
1499        .as_deref()
1500        .and_then(authority_of)
1501        .or_else(|| referer.as_deref().and_then(authority_of));
1502
1503    match (source_authority, host) {
1504        // Neither Origin nor Referer present: treat as a non-browser client.
1505        (None, _) => next.run(req).await,
1506        (Some(src), Some(h)) if src == h => next.run(req).await,
1507        (Some(src), host) => {
1508            tracing::warn!(
1509                event = "csrf_rejected",
1510                path = %path,
1511                origin = %src,
1512                host = ?host,
1513                "Cross-origin state-changing request rejected (CSRF guard)"
1514            );
1515            (
1516                StatusCode::FORBIDDEN,
1517                "403 Forbidden — cross-origin request rejected\n",
1518            )
1519                .into_response()
1520        }
1521    }
1522}
1523
1524/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1525/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1526/// is overkill — a short opacity fade gives a smooth page-to-page transition
1527/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1528/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1529/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1530/// light-mode flash for dark-theme users.
1531fn page_fade_html(nonce: &str) -> String {
1532    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1533    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1534    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1535    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1536    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1537    // through the entire body parse, which reads as a delay before navigation "begins"
1538    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1539    // 0 -> 1 cleanly, with no pre-paint hold.
1540    const STYLE: &str = r"<style>
1541@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1542.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1543body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1544@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1545</style>";
1546    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1547    // The click handler gives immediate feedback by fading the *content* out the moment a
1548    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1549    // preventDefault or delay navigation — the browser navigates instantly and the fade
1550    // plays opportunistically during the natural fetch window, so no latency is added.
1551    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1552    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1553    // if the click was actually a download (no unload) or the page is restored from bfcache.
1554    const JS: &str = r"(function(){try{if(localStorage.getItem('sloc-dark')==='1'&&document.body)document.body.classList.add('dark-theme');}catch(e){}function leave(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;if(!a)return;if(a.target&&a.target!=='_self')return;if(a.hasAttribute('download'))return;var href=a.getAttribute('href');if(!href||href.charAt(0)==='#')return;if(/^(mailto:|tel:|javascript:)/i.test(href))return;var u;try{u=new URL(a.href,location.href);}catch(_){return;}if(u.origin!==location.origin)return;if(u.pathname===location.pathname&&u.search===location.search)return;var b=document.body;if(!b)return;b.classList.add('sloc-leaving');setTimeout(function(){b.classList.remove('sloc-leaving');},1400);}document.addEventListener('click',leave);window.addEventListener('pageshow',function(){if(document.body)document.body.classList.remove('sloc-leaving');});})();";
1555    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1556}
1557
1558/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1559/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1560/// `<script>` — meant to be spliced in immediately after `<body>`.
1561///
1562/// It pairs the spinner with a **visibility gate**: from the first byte the page
1563/// content is held at `visibility:hidden` (only the overlay paints), so the user
1564/// never sees a half-rendered flash while charts/tables are still settling. On
1565/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1566/// still-opaque overlay, which then fades out one frame later — so the reveal is
1567/// of a finished page, with no glitch on either side of the transition.
1568///
1569/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1570/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1571/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1572fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1573    const TPL: &str = r#"<style nonce="__N__">
1574html.sloc-pending body{visibility:hidden;}
1575html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1576#rpt-loading-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .45s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
1577#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1578body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1579body.pdf-mode #rpt-loading-overlay{display:none!important;}
1580.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1581.rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
1582.rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
1583@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1584@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1585body.dark-theme .rpt-bg-blob{opacity:.36;}
1586.rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:20px;width:380px;max-width:88vw;padding:42px 50px 34px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:24px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
1587@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1588body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
1589.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1590.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1591.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1592.rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
1593@keyframes rpt-spin{to{transform:rotate(360deg);}}
1594.rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
1595body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1596body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1597.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1598.rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
1599@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1600.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1601.rpt-dot:nth-child(2){animation-delay:.28s;}
1602.rpt-dot:nth-child(3){animation-delay:.56s;}
1603@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1604.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1605.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1606.rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
1607body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1608@media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
1609</style>
1610<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1611<script nonce="__N__">document.documentElement.classList.add('sloc-pending');try{if(localStorage.getItem('sloc-dark')==='1'||localStorage.getItem('oxide-sloc-theme')==='dark')document.body.classList.add('dark-theme');}catch(e){}</script>
1612<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1613  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1614  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1615  <div class="rpt-load-card">
1616    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1617    <div class="rpt-spinner-wrap">
1618      <div class="rpt-spinner-track"></div>
1619      <div class="rpt-spinner"></div>
1620      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1621    </div>
1622    <div class="rpt-loading-text"><span class="rpt-load-word">Loading comparison</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
1623    <div class="rpt-status" id="rpt-status">__LABEL__</div>
1624    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1625  </div>
1626</div>
1627<script nonce="__N__">
1628(function(){
1629  var ov=document.getElementById('rpt-loading-overlay');
1630  var root=document.documentElement;
1631  function reveal(){root.classList.remove('sloc-pending');}
1632  if(!ov){reveal();return;}
1633  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1634  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1635  var mi=0,prog=0,done=false,start=Date.now();
1636  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1637  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1638  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1639  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1640  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1641  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1642  setProg(8);
1643  var msgTimer=setInterval(nextMsg,700);
1644  var progTimer=setInterval(function(){var cap=99;if(prog<cap){var step=(cap-prog)*0.05+0.4;setProg(Math.min(cap,prog+step));}},90);
1645  // These pages draw charts into known SVG containers that start empty and are
1646  // filled by JS once layout is available (some only after a ResizeObserver pass
1647  // post-`load`). Treat the page as ready only once every chart container present
1648  // actually has rendered content, so the overlay never lifts on a half-drawn page.
1649  function chartsRendered(){
1650    var sel=['#cmp-tl-svg','#mc-chart'];
1651    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1652    return true;
1653  }
1654  function finish(){
1655    if(done)return;done=true;
1656    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1657    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1658    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1659    reveal();
1660    requestAnimationFrame(function(){requestAnimationFrame(function(){
1661      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1662    });});
1663  }
1664  // Wait for `load` (resources + first layout), then poll until the charts have
1665  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1666  function afterLoad(){
1667    var loadAt=Date.now();
1668    (function poll(){
1669      if(done)return;
1670      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1671        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1672        return;
1673      }
1674      requestAnimationFrame(poll);
1675    })();
1676  }
1677  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1678  // Absolute safety net: never let the gate/overlay get stuck.
1679  setTimeout(function(){if(!done)finish();},HARD_CAP);
1680})();
1681</script>"#;
1682    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1683}
1684
1685/// Shared toast-notification assets + a global PDF-export helper, spliced into
1686/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
1687/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
1688/// placed just before `</body>`.
1689///
1690/// It defines two globals:
1691/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
1692///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
1693///   toast stays up until its returned handle's `.dismiss()` is called.
1694/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
1695///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
1696///   `/export/pdf`, triggers the download, then raises a success or error toast and
1697///   restores the button. Centralising this guarantees identical, obvious feedback
1698///   everywhere instead of a silent `alert()`-only failure path.
1699fn sloc_toast_assets(nonce: &str) -> String {
1700    const TPL: &str = r##"<style nonce="__N__">
1701#sloc-toast-wrap{position:fixed;right:18px;top:18px;z-index:11000;display:flex;flex-direction:column;gap:10px;max-width:min(380px,calc(100vw - 36px));pointer-events:none;}
1702.sloc-toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:12px;background:#fcfaf7;color:#2f241c;border:1px solid #dfcfbf;box-shadow:0 12px 32px rgba(77,44,20,0.22);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;font-weight:600;line-height:1.35;opacity:0;transform:translateY(12px) scale(.96);transition:opacity .26s ease,transform .26s cubic-bezier(.22,.68,0,1.12);}
1703.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1704.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1705.sloc-toast-ico{flex:0 0 auto;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;font-style:normal;}
1706.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1707.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1708.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1709.sloc-toast-success{border-color:#bfe0cc;}
1710.sloc-toast-error{border-color:#e6b3b3;}
1711.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1712.sloc-toast-spin{flex:0 0 auto;width:18px;height:18px;border-radius:50%;border:2.5px solid rgba(196,92,16,.25);border-top-color:#c45c10;animation:sloc-toast-spin .7s linear infinite;}
1713@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1714.sloc-toast-x{flex:0 0 auto;background:none;border:none;color:inherit;opacity:.5;cursor:pointer;font-size:16px;line-height:1;padding:0 2px;margin:-1px -2px 0 2px;}
1715.sloc-toast-x:hover{opacity:1;}
1716body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1717body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1718body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1719body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1720@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1721</style>
1722<script nonce="__N__">
1723(function(){
1724  if(window.slocToast)return;
1725  function wrap(){
1726    var w=document.getElementById('sloc-toast-wrap');
1727    if(!w){w=document.createElement('div');w.id='sloc-toast-wrap';w.setAttribute('aria-live','polite');w.setAttribute('aria-atomic','false');(document.body||document.documentElement).appendChild(w);}
1728    return w;
1729  }
1730  window.slocToast=function(msg,opts){
1731    opts=opts||{};
1732    var type=opts.type||'info';
1733    var loading=type==='loading';
1734    var t=document.createElement('div');
1735    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1736    t.setAttribute('role',type==='error'?'alert':'status');
1737    var ico=loading
1738      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1739      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1740    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1741    t.querySelector('.sloc-toast-msg').textContent=String(msg);
1742    wrap().appendChild(t);
1743    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1744    var gone=false,timer=null;
1745    function close(){
1746      if(gone)return;gone=true;if(timer)clearTimeout(timer);
1747      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1748      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1749    }
1750    t.querySelector('.sloc-toast-x').addEventListener('click',close);
1751    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1752    if(ttl>0)timer=setTimeout(close,ttl);
1753    return {dismiss:close,el:t};
1754  };
1755  window.slocExportPdf=function(o){
1756    o=o||{};
1757    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1758    if(btn&&btn.disabled)return;
1759    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1760    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1761    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1762      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1763      .then(function(blob){
1764        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1765        document.body.appendChild(a);a.click();document.body.removeChild(a);
1766        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1767        load.dismiss();
1768        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1769      })
1770      .catch(function(e){
1771        load.dismiss();
1772        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1773      })
1774      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1775  };
1776})();
1777</script>"##;
1778    TPL.replace("__N__", nonce)
1779}
1780
1781/// Buffer an HTML response body and splice the page fade-in right after the
1782/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
1783/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
1784/// branded loading spinner for slow renders).
1785async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1786    let is_html = resp
1787        .headers()
1788        .get(header::CONTENT_TYPE)
1789        .and_then(|v| v.to_str().ok())
1790        .is_some_and(|v| v.starts_with("text/html"));
1791    if !is_html {
1792        return;
1793    }
1794    let body = std::mem::replace(resp.body_mut(), Body::empty());
1795    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1796        return;
1797    };
1798    let html = match String::from_utf8(bytes.to_vec()) {
1799        Ok(s) => s,
1800        Err(e) => {
1801            *resp.body_mut() = Body::from(e.into_bytes());
1802            return;
1803        }
1804    };
1805    if html.contains("id=\"rpt-loading-overlay\"") {
1806        *resp.body_mut() = Body::from(html);
1807        return;
1808    }
1809    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
1810    // avoids allocating a lowercased copy of the whole document on every request.
1811    // Fall back to a case-insensitive scan only if that fails (rare/never).
1812    let insert_at = html
1813        .find("<body")
1814        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1815        .or_else(|| {
1816            let lower = html.to_ascii_lowercase();
1817            lower
1818                .find("<body")
1819                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1820        });
1821    let new_html = match insert_at {
1822        Some(at) => {
1823            let mut out = String::with_capacity(html.len() + 1024);
1824            out.push_str(&html[..at]);
1825            out.push_str(&page_fade_html(nonce));
1826            out.push_str(&html[at..]);
1827            out
1828        }
1829        None => html,
1830    };
1831    resp.headers_mut().remove(header::CONTENT_LENGTH);
1832    *resp.body_mut() = Body::from(new_html);
1833}
1834
1835async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1836    let peer_ip = req
1837        .extensions()
1838        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1839        .map(|c| c.0.ip());
1840
1841    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1842    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1843    // header spoofing from direct connections.
1844    let ip = peer_ip
1845        .and_then(|peer| {
1846            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1847                req.headers()
1848                    .get("X-Forwarded-For")
1849                    .and_then(|v| v.to_str().ok())
1850                    .and_then(|s| s.split(',').next())
1851                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1852            } else {
1853                None
1854            }
1855        })
1856        .or(peer_ip)
1857        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1858
1859    if !state.rate_limiter.is_allowed(ip) {
1860        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1861            path = %req.uri().path(), "Rate limit exceeded");
1862        return (
1863            StatusCode::TOO_MANY_REQUESTS,
1864            [(header::RETRY_AFTER, "60")],
1865            "429 Too Many Requests\n",
1866        )
1867            .into_response();
1868    }
1869    next.run(req).await
1870}
1871
1872async fn splash(
1873    State(state): State<AppState>,
1874    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1875) -> impl IntoResponse {
1876    let lan_ip = if state.server_mode {
1877        primary_lan_ip()
1878    } else {
1879        None
1880    };
1881    let port = state
1882        .base_config
1883        .web
1884        .bind_address
1885        .rsplit(':')
1886        .next()
1887        .and_then(|p| p.parse::<u16>().ok())
1888        .unwrap_or(4317);
1889    let has_api_key = !state.api_keys.is_empty();
1890    let template = SplashTemplate {
1891        csp_nonce,
1892        server_mode: state.server_mode,
1893        lan_ip,
1894        port,
1895        version: env!("CARGO_PKG_VERSION"),
1896        has_api_key,
1897    };
1898    Html(
1899        template
1900            .render()
1901            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1902    )
1903}
1904
1905async fn index(
1906    State(state): State<AppState>,
1907    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1908    Query(query): Query<IndexQuery>,
1909) -> impl IntoResponse {
1910    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1911        let policy = query
1912            .mixed_line_policy
1913            .unwrap_or_else(|| "code_only".to_string());
1914        let behavior = query
1915            .binary_file_behavior
1916            .unwrap_or_else(|| "skip".to_string());
1917        let cfg = ScanConfig {
1918            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1919            path: query.path.unwrap_or_default(),
1920            include_globs: query.include_globs.unwrap_or_default(),
1921            exclude_globs: query.exclude_globs.unwrap_or_default(),
1922            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1923            mixed_line_policy: policy,
1924            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1925                != Some("off"),
1926            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1927            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1928            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1929                != Some("disabled"),
1930            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1931            binary_file_behavior: behavior,
1932            output_dir: query.output_dir.unwrap_or_default(),
1933            report_title: query.report_title.unwrap_or_default(),
1934            continuation_line_policy: query
1935                .continuation_line_policy
1936                .unwrap_or_else(default_each_physical_line),
1937            blank_in_block_comment_policy: query
1938                .blank_in_block_comment_policy
1939                .unwrap_or_else(default_count_as_comment),
1940            count_compiler_directives: query.count_compiler_directives.as_deref()
1941                != Some("disabled"),
1942            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
1943            style_col_threshold: query
1944                .style_col_threshold
1945                .as_deref()
1946                .and_then(|s| s.parse().ok())
1947                .unwrap_or(80),
1948            style_score_threshold: query
1949                .style_score_threshold
1950                .as_deref()
1951                .and_then(|s| s.parse().ok())
1952                .unwrap_or(0),
1953            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
1954            coverage_file: query.coverage_file.unwrap_or_default(),
1955            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
1956            complexity_alert: query
1957                .complexity_alert
1958                .as_deref()
1959                .and_then(|s| s.parse().ok())
1960                .unwrap_or(0),
1961            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
1962            activity_window: query
1963                .activity_window
1964                .as_deref()
1965                .and_then(|s| s.parse().ok())
1966                .unwrap_or(90),
1967        };
1968        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1969    } else {
1970        "{}".to_string()
1971    };
1972
1973    let git_repo = query.git_repo.unwrap_or_default();
1974    let git_ref = query.git_ref.unwrap_or_default();
1975
1976    let git_label = make_git_label(&git_repo, &git_ref);
1977    let git_output_dir = if git_label.is_empty() {
1978        String::new()
1979    } else {
1980        desktop_dir().join(&git_label).display().to_string()
1981    };
1982    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1983    let git_output_dir_json =
1984        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1985
1986    let template = IndexTemplate {
1987        version: env!("CARGO_PKG_VERSION"),
1988        prefill_json,
1989        csp_nonce,
1990        git_repo,
1991        git_ref,
1992        git_label_json,
1993        git_output_dir_json,
1994        server_mode: state.server_mode,
1995    };
1996
1997    Html(
1998        template
1999            .render()
2000            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2001    )
2002}
2003
2004async fn scan_setup_handler(
2005    State(state): State<AppState>,
2006    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2007) -> impl IntoResponse {
2008    let recent_scans_json = {
2009        let arr: Vec<serde_json::Value> = {
2010            let reg = state.registry.lock().await;
2011            reg.entries
2012                .iter()
2013                .rev()
2014                .take(6)
2015                .map(|e| {
2016                    let run_dir = e
2017                        .html_path
2018                        .as_ref()
2019                        .or(e.json_path.as_ref())
2020                        .and_then(|p| p.parent().map(PathBuf::from));
2021                    let config_val: Option<serde_json::Value> = run_dir
2022                        .and_then(|d| find_scan_config_in_dir(&d))
2023                        .and_then(|p| fs::read_to_string(&p).ok())
2024                        .and_then(|s| serde_json::from_str(&s).ok());
2025                    serde_json::json!({
2026                        "project_label": e.project_label,
2027                        "timestamp": fmt_la_time(e.timestamp_utc),
2028                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2029                        "config": config_val,
2030                    })
2031                })
2032                .collect()
2033        };
2034        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2035    };
2036
2037    let template = ScanSetupTemplate {
2038        version: env!("CARGO_PKG_VERSION"),
2039        recent_scans_json,
2040        csp_nonce,
2041    };
2042    Html(
2043        template
2044            .render()
2045            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2046    )
2047}
2048
2049async fn healthz() -> &'static str {
2050    "ok"
2051}
2052
2053async fn api_version_handler() -> impl IntoResponse {
2054    axum::Json(serde_json::json!({
2055        "name": "oxide-sloc",
2056        "version": env!("CARGO_PKG_VERSION"),
2057    }))
2058}
2059
2060// ── Prometheus metrics ────────────────────────────────────────────────────────
2061
2062fn prom_runs_total() -> &'static prometheus::IntCounter {
2063    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2064    COUNTER.get_or_init(|| {
2065        prometheus::register_int_counter!(
2066            "oxide_sloc_runs_total",
2067            "Total number of completed analysis runs"
2068        )
2069        .expect("failed to register oxide_sloc_runs_total counter")
2070    })
2071}
2072
2073async fn metrics_handler() -> impl IntoResponse {
2074    use prometheus::Encoder as _;
2075    let mut buf = Vec::new();
2076    let encoder = prometheus::TextEncoder::new();
2077    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2078    (
2079        [(
2080            axum::http::header::CONTENT_TYPE,
2081            "text/plain; version=0.0.4; charset=utf-8",
2082        )],
2083        buf,
2084    )
2085}
2086
2087static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2088
2089async fn openapi_yaml_handler() -> impl IntoResponse {
2090    (
2091        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2092        OPENAPI_YAML,
2093    )
2094}
2095
2096static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2097static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2098
2099async fn llms_txt_handler() -> impl IntoResponse {
2100    (
2101        [
2102            (
2103                axum::http::header::CONTENT_TYPE,
2104                "text/plain; charset=utf-8",
2105            ),
2106            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2107        ],
2108        LLMS_TXT,
2109    )
2110}
2111
2112async fn llms_full_txt_handler() -> impl IntoResponse {
2113    (
2114        [
2115            (
2116                axum::http::header::CONTENT_TYPE,
2117                "text/plain; charset=utf-8",
2118            ),
2119            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2120        ],
2121        LLMS_FULL_TXT,
2122    )
2123}
2124
2125async fn api_docs_handler(
2126    State(state): State<AppState>,
2127    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2128) -> impl IntoResponse {
2129    let has_api_key = !state.api_keys.is_empty();
2130    Html(
2131        ApiDocsTemplate {
2132            has_api_key,
2133            csp_nonce,
2134            version: env!("CARGO_PKG_VERSION"),
2135        }
2136        .render()
2137        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2138    )
2139}
2140
2141async fn chart_js_handler() -> impl IntoResponse {
2142    (
2143        [
2144            (
2145                header::CONTENT_TYPE,
2146                "application/javascript; charset=utf-8",
2147            ),
2148            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2149        ],
2150        CHART_JS,
2151    )
2152}
2153
2154async fn report_chart_js_handler() -> impl IntoResponse {
2155    (
2156        [
2157            (
2158                header::CONTENT_TYPE,
2159                "application/javascript; charset=utf-8",
2160            ),
2161            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2162        ],
2163        REPORT_CHART_JS,
2164    )
2165}
2166
2167#[derive(Debug, Deserialize)]
2168struct AnalyzeForm {
2169    path: String,
2170    git_repo: Option<String>,
2171    git_ref: Option<String>,
2172    mixed_line_policy: Option<MixedLinePolicy>,
2173    python_docstrings_as_comments: Option<String>,
2174    generated_file_detection: Option<String>,
2175    minified_file_detection: Option<String>,
2176    vendor_directory_detection: Option<String>,
2177    include_lockfiles: Option<String>,
2178    binary_file_behavior: Option<BinaryFileBehavior>,
2179    output_dir: Option<String>,
2180    report_title: Option<String>,
2181    report_header_footer: Option<String>,
2182    include_globs: Option<String>,
2183    exclude_globs: Option<String>,
2184    submodule_breakdown: Option<String>,
2185    coverage_file: Option<String>,
2186    continuation_line_policy: Option<ContinuationLinePolicy>,
2187    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2188    count_compiler_directives: Option<String>,
2189    style_col_threshold: Option<String>,
2190    style_analysis_enabled: Option<String>,
2191    style_score_threshold: Option<String>,
2192    style_lang_scope: Option<String>,
2193    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2194    cocomo_mode: Option<String>,
2195    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2196    complexity_alert: Option<String>,
2197    /// Whether to exclude duplicate files from displayed SLOC totals.
2198    exclude_duplicates: Option<String>,
2199    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2200    activity_window: Option<String>,
2201}
2202
2203#[allow(clippy::struct_excessive_bools)]
2204#[derive(Debug, Serialize, Deserialize, Clone)]
2205struct ScanConfig {
2206    oxide_sloc_version: String,
2207    path: String,
2208    include_globs: String,
2209    exclude_globs: String,
2210    submodule_breakdown: bool,
2211    mixed_line_policy: String,
2212    python_docstrings_as_comments: bool,
2213    generated_file_detection: bool,
2214    minified_file_detection: bool,
2215    vendor_directory_detection: bool,
2216    include_lockfiles: bool,
2217    binary_file_behavior: String,
2218    output_dir: String,
2219    report_title: String,
2220    // IEEE 1045-1992 and advanced fields added in later release
2221    #[serde(default = "default_each_physical_line")]
2222    continuation_line_policy: String,
2223    #[serde(default = "default_count_as_comment")]
2224    blank_in_block_comment_policy: String,
2225    #[serde(default = "default_true_bool")]
2226    count_compiler_directives: bool,
2227    #[serde(default = "default_true_bool")]
2228    style_analysis_enabled: bool,
2229    #[serde(default = "default_style_col_threshold")]
2230    style_col_threshold: u16,
2231    #[serde(default)]
2232    style_score_threshold: u8,
2233    #[serde(default = "default_all_scope")]
2234    style_lang_scope: String,
2235    #[serde(default)]
2236    coverage_file: String,
2237    #[serde(default = "default_organic")]
2238    cocomo_mode: String,
2239    #[serde(default)]
2240    complexity_alert: u32,
2241    #[serde(default)]
2242    exclude_duplicates: bool,
2243    /// Git hotspots activity window in days (on by default; 0 = disabled).
2244    #[serde(default = "default_activity_window")]
2245    activity_window: u32,
2246}
2247
2248const fn default_activity_window() -> u32 {
2249    90
2250}
2251
2252fn default_each_physical_line() -> String {
2253    "each_physical_line".to_string()
2254}
2255fn default_count_as_comment() -> String {
2256    "count_as_comment".to_string()
2257}
2258const fn default_true_bool() -> bool {
2259    true
2260}
2261const fn default_style_col_threshold() -> u16 {
2262    80
2263}
2264fn default_all_scope() -> String {
2265    "all".to_string()
2266}
2267fn default_organic() -> String {
2268    "organic".to_string()
2269}
2270
2271#[derive(Debug, Deserialize, Default)]
2272struct IndexQuery {
2273    path: Option<String>,
2274    include_globs: Option<String>,
2275    exclude_globs: Option<String>,
2276    submodule_breakdown: Option<String>,
2277    mixed_line_policy: Option<String>,
2278    python_docstrings_as_comments: Option<String>,
2279    generated_file_detection: Option<String>,
2280    minified_file_detection: Option<String>,
2281    vendor_directory_detection: Option<String>,
2282    include_lockfiles: Option<String>,
2283    binary_file_behavior: Option<String>,
2284    output_dir: Option<String>,
2285    report_title: Option<String>,
2286    prefilled: Option<String>,
2287    git_repo: Option<String>,
2288    git_ref: Option<String>,
2289    // IEEE 1045-1992 and advanced fields
2290    continuation_line_policy: Option<String>,
2291    blank_in_block_comment_policy: Option<String>,
2292    count_compiler_directives: Option<String>,
2293    style_analysis_enabled: Option<String>,
2294    style_col_threshold: Option<String>,
2295    style_score_threshold: Option<String>,
2296    style_lang_scope: Option<String>,
2297    coverage_file: Option<String>,
2298    cocomo_mode: Option<String>,
2299    complexity_alert: Option<String>,
2300    exclude_duplicates: Option<String>,
2301    activity_window: Option<String>,
2302}
2303
2304#[derive(Debug, Deserialize)]
2305struct PreviewQuery {
2306    path: Option<String>,
2307    include_globs: Option<String>,
2308    exclude_globs: Option<String>,
2309}
2310
2311#[cfg(feature = "native-dialog")]
2312#[derive(Debug, Deserialize)]
2313struct PickDirectoryQuery {
2314    kind: Option<String>,
2315    current: Option<String>,
2316}
2317
2318#[cfg(not(feature = "native-dialog"))]
2319#[derive(Debug, Deserialize)]
2320struct PickDirectoryQuery {}
2321
2322#[derive(Debug, Deserialize, Default)]
2323struct ArtifactQuery {
2324    download: Option<String>,
2325}
2326
2327#[cfg(feature = "native-dialog")]
2328#[derive(Debug, Serialize)]
2329struct PickDirectoryResponse {
2330    selected_path: Option<String>,
2331    cancelled: bool,
2332}
2333
2334#[cfg(feature = "native-dialog")]
2335async fn pick_directory_handler(
2336    State(state): State<AppState>,
2337    Query(query): Query<PickDirectoryQuery>,
2338) -> Response {
2339    if state.server_mode {
2340        return StatusCode::NOT_FOUND.into_response();
2341    }
2342    // Return immediately without opening a dialog in headless / CI environments.
2343    if std::env::var("SLOC_HEADLESS").is_ok() {
2344        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2345            .into_response();
2346    }
2347
2348    let is_coverage = query.kind.as_deref() == Some("coverage");
2349    let title = match query.kind.as_deref() {
2350        Some("output") => "Select output directory",
2351        Some("reports") => "Select folder containing saved reports",
2352        Some("coverage") => "Select LCOV coverage file",
2353        _ => "Select project directory",
2354    }
2355    .to_owned();
2356    let current = query.current.clone();
2357
2358    let picked = tokio::task::spawn_blocking(move || {
2359        // Windows: attach to the foreground thread so the dialog inherits focus,
2360        // and kick off a watcher that flashes the dialog once it appears.
2361        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2362        let fg_tid = win_dialog_focus::attach_to_foreground();
2363        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2364        win_dialog_focus::flash_dialog_when_ready(title.clone());
2365
2366        let mut dialog = rfd::FileDialog::new().set_title(&title);
2367        if let Some(current) = current.as_deref() {
2368            let resolved = resolve_input_path(current);
2369            let seed = if resolved.is_dir() {
2370                Some(resolved)
2371            } else {
2372                resolved.parent().map(Path::to_path_buf)
2373            };
2374            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2375                dialog = dialog.set_directory(seed_dir);
2376            }
2377        }
2378        let result = if is_coverage {
2379            dialog
2380                .add_filter(
2381                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2382                    &["info", "lcov", "xml", "json"],
2383                )
2384                .pick_file()
2385        } else {
2386            dialog.pick_folder()
2387        };
2388
2389        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2390        win_dialog_focus::detach_from_foreground(fg_tid);
2391
2392        result
2393    })
2394    .await
2395    .unwrap_or(None);
2396
2397    Json(PickDirectoryResponse {
2398        selected_path: picked.as_ref().map(|p| display_path(p)),
2399        cancelled: picked.is_none(),
2400    })
2401    .into_response()
2402}
2403
2404#[cfg(not(feature = "native-dialog"))]
2405async fn pick_directory_handler(
2406    State(_state): State<AppState>,
2407    Query(_query): Query<PickDirectoryQuery>,
2408) -> Response {
2409    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2410}
2411
2412#[cfg(feature = "native-dialog")]
2413async fn pick_file_handler(State(state): State<AppState>) -> Response {
2414    if state.server_mode {
2415        return StatusCode::NOT_FOUND.into_response();
2416    }
2417    if std::env::var("SLOC_HEADLESS").is_ok() {
2418        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2419            .into_response();
2420    }
2421    let picked = tokio::task::spawn_blocking(|| {
2422        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2423        let fg_tid = win_dialog_focus::attach_to_foreground();
2424        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2425        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2426
2427        let result = rfd::FileDialog::new()
2428            .set_title("Select HTML report")
2429            .add_filter("HTML report", &["html"])
2430            .pick_file();
2431
2432        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2433        win_dialog_focus::detach_from_foreground(fg_tid);
2434
2435        result
2436    })
2437    .await
2438    .unwrap_or(None);
2439    Json(PickDirectoryResponse {
2440        selected_path: picked.as_ref().map(|p| display_path(p)),
2441        cancelled: picked.is_none(),
2442    })
2443    .into_response()
2444}
2445
2446#[cfg(not(feature = "native-dialog"))]
2447async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2448    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2449}
2450
2451// ── Browser-upload handlers (server mode only) ────────────────────────────────
2452
2453/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2454/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2455fn is_upload_tmp_path(path: &Path) -> bool {
2456    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2457    path.starts_with(&upload_root)
2458}
2459
2460/// Returns true when `path` is the built-in sample or test-fixture directory.
2461/// These paths ship with the server binary and are always safe to scan/preview.
2462fn is_sample_path(path: &Path) -> bool {
2463    let root = workspace_root();
2464    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2465}
2466
2467/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2468fn upload_base_dir() -> PathBuf {
2469    std::env::temp_dir().join("oxide-sloc-uploads")
2470}
2471
2472/// Returns the staging path for a given upload id inside the base dir.
2473fn upload_staging_path(id: &str) -> PathBuf {
2474    upload_base_dir().join(id)
2475}
2476
2477/// Validate basic field constraints on a directory-upload request.
2478/// Returns an error `Response` if the request should be rejected immediately.
2479#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2480fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2481    const MAX_FILES: usize = 50_000;
2482    if body.files.is_empty() {
2483        return Err((
2484            StatusCode::BAD_REQUEST,
2485            Json(serde_json::json!({"error": "No files received"})),
2486        )
2487            .into_response());
2488    }
2489    if body.files.len() > MAX_FILES {
2490        return Err((
2491            StatusCode::PAYLOAD_TOO_LARGE,
2492            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2493        )
2494            .into_response());
2495    }
2496    Ok(())
2497}
2498
2499/// Resolve or create the staging directory for a directory upload.
2500/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2501fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2502    match id {
2503        Some(id)
2504            if !id.is_empty()
2505                && id.len() <= 36
2506                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2507        {
2508            (id.to_string(), upload_staging_path(id))
2509        }
2510        _ => {
2511            let new_id = uuid::Uuid::new_v4().to_string();
2512            let staging = upload_staging_path(&new_id);
2513            (new_id, staging)
2514        }
2515    }
2516}
2517
2518/// Decode, size-check, and write one uploaded file entry into `staging`.
2519/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2520/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2521/// cleaning up `staging` before propagating the error.
2522#[allow(clippy::result_large_err)]
2523async fn stage_decoded_entry(
2524    entry: &UploadedFile,
2525    staging: &Path,
2526    total_bytes: &mut usize,
2527    project_root: &mut Option<PathBuf>,
2528) -> Result<(), Response> {
2529    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2530
2531    let Ok(data) = base64::Engine::decode(
2532        &base64::engine::general_purpose::STANDARD,
2533        entry.content.as_bytes(),
2534    ) else {
2535        return Ok(());
2536    };
2537
2538    *total_bytes += data.len();
2539    if *total_bytes > MAX_TOTAL_BYTES {
2540        return Err((
2541            StatusCode::PAYLOAD_TOO_LARGE,
2542            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2543        )
2544            .into_response());
2545    }
2546
2547    let rel = std::path::Path::new(&entry.path);
2548    if project_root.is_none() {
2549        if let Some(first) = rel.components().next() {
2550            *project_root = Some(staging.join(first.as_os_str()));
2551        }
2552    }
2553
2554    let dest = staging.join(rel);
2555    if let Some(parent) = dest.parent() {
2556        if tokio::fs::create_dir_all(parent).await.is_err() {
2557            return Err((
2558                StatusCode::INTERNAL_SERVER_ERROR,
2559                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2560            )
2561                .into_response());
2562        }
2563    }
2564
2565    if tokio::fs::write(&dest, &data).await.is_err() {
2566        return Err((
2567            StatusCode::INTERNAL_SERVER_ERROR,
2568            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2569        )
2570            .into_response());
2571    }
2572
2573    Ok(())
2574}
2575
2576/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2577/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2578/// an error `Response` on failure (staging dir is cleaned up before returning).
2579async fn write_upload_files(
2580    files: &[UploadedFile],
2581    staging: &Path,
2582    upload_id: &str,
2583) -> Result<(usize, Option<PathBuf>), Response> {
2584    let mut total_bytes: usize = 0;
2585    let mut project_root: Option<PathBuf> = None;
2586
2587    for entry in files {
2588        let rel = std::path::Path::new(&entry.path);
2589        if rel
2590            .components()
2591            .any(|c| matches!(c, std::path::Component::ParentDir))
2592        {
2593            // Reject the entire upload on the first path traversal attempt.
2594            let _ = tokio::fs::remove_dir_all(staging).await;
2595            tracing::warn!(
2596                event = "upload_path_traversal",
2597                upload_id = %upload_id,
2598                path = %entry.path,
2599                "Upload rejected: path traversal component detected"
2600            );
2601            return Err((
2602                StatusCode::BAD_REQUEST,
2603                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2604            )
2605                .into_response());
2606        }
2607
2608        if let Err(resp) =
2609            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2610        {
2611            let _ = tokio::fs::remove_dir_all(staging).await;
2612            return Err(resp);
2613        }
2614    }
2615
2616    Ok((files.len(), project_root))
2617}
2618
2619/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2620/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2621fn parse_tarball_size_caps() -> (u64, u64) {
2622    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2623        .ok()
2624        .and_then(|v| v.parse().ok())
2625        .unwrap_or(2048_u64)
2626        * 1024
2627        * 1024;
2628    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2629        .ok()
2630        .and_then(|v| v.parse().ok())
2631        .unwrap_or(10_240_u64)
2632        * 1024
2633        * 1024;
2634    (compressed, decompressed)
2635}
2636
2637/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
2638/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
2639/// are rejected before the streaming handler is invoked.
2640fn tarball_http_body_limit_bytes() -> usize {
2641    std::env::var("SLOC_MAX_TARBALL_MB")
2642        .ok()
2643        .and_then(|v| v.parse::<usize>().ok())
2644        .unwrap_or(2048)
2645        .saturating_mul(1024 * 1024)
2646}
2647
2648/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2649/// Returns the number of compressed bytes written, or an error `Response`.
2650/// Cleans up `dest_path` on error.
2651#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2652async fn stream_body_to_file(
2653    body: axum::body::Body,
2654    dest_path: &Path,
2655    max_bytes: u64,
2656) -> Result<u64, Response> {
2657    use http_body_util::BodyExt as _;
2658    use tokio::io::AsyncWriteExt as _;
2659
2660    let mut file = match tokio::fs::File::create(dest_path).await {
2661        Ok(f) => f,
2662        Err(e) => {
2663            tracing::error!(
2664                event = "upload_io_error",
2665                "failed to create tarball temp file: {e}"
2666            );
2667            return Err((
2668                StatusCode::INTERNAL_SERVER_ERROR,
2669                Json(serde_json::json!({"error": "Upload initialization failed"})),
2670            )
2671                .into_response());
2672        }
2673    };
2674
2675    let mut body = body;
2676    let mut written: u64 = 0;
2677    loop {
2678        match body.frame().await {
2679            None => break,
2680            Some(Err(e)) => {
2681                let _ = tokio::fs::remove_file(dest_path).await;
2682                return Err((
2683                    StatusCode::BAD_REQUEST,
2684                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2685                )
2686                    .into_response());
2687            }
2688            Some(Ok(frame)) => {
2689                if let Ok(data) = frame.into_data() {
2690                    written += data.len() as u64;
2691                    if written > max_bytes {
2692                        let _ = tokio::fs::remove_file(dest_path).await;
2693                        return Err((
2694                            StatusCode::PAYLOAD_TOO_LARGE,
2695                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2696                        )
2697                            .into_response());
2698                    }
2699                    if let Err(e) = file.write_all(&data).await {
2700                        let _ = tokio::fs::remove_file(dest_path).await;
2701                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2702                        return Err((
2703                            StatusCode::INTERNAL_SERVER_ERROR,
2704                            Json(serde_json::json!({"error": "Upload write failed"})),
2705                        )
2706                            .into_response());
2707                    }
2708                }
2709            }
2710        }
2711    }
2712    drop(file);
2713    Ok(written)
2714}
2715
2716/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2717/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2718/// on failure (staging dir is cleaned up before returning).
2719#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2720async fn extract_tarball_to_staging(
2721    tarball_path: &Path,
2722    staging: &Path,
2723    max_decompressed_bytes: u64,
2724) -> Result<(), Response> {
2725    let staging_clone = staging.to_path_buf();
2726    let tarball_clone = tarball_path.to_path_buf();
2727    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2728        let file = std::fs::File::open(&tarball_clone)?;
2729        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2730        let limited = SizeLimitReader {
2731            inner: gz,
2732            remaining: max_decompressed_bytes,
2733        };
2734        let mut archive = tar::Archive::new(limited);
2735        archive.set_overwrite(true);
2736        archive.set_preserve_permissions(false);
2737        std::fs::create_dir_all(&staging_clone)?;
2738        archive.unpack(&staging_clone)?;
2739        Ok(())
2740    })
2741    .await;
2742    let _ = tokio::fs::remove_file(tarball_path).await;
2743
2744    match extract_result {
2745        Ok(Ok(())) => Ok(()),
2746        Ok(Err(e)) => {
2747            let _ = tokio::fs::remove_dir_all(staging).await;
2748            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2749            tracing::warn!(
2750                event = "upload_extract_error",
2751                "tarball extraction failed: {e:#}"
2752            );
2753            let (status, msg) = if is_size_limit {
2754                (
2755                    StatusCode::PAYLOAD_TOO_LARGE,
2756                    "Archive exceeds the decompressed size limit",
2757                )
2758            } else {
2759                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2760            };
2761            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2762        }
2763        Err(e) => {
2764            let _ = tokio::fs::remove_dir_all(staging).await;
2765            tracing::error!(
2766                event = "upload_extract_panic",
2767                "tarball extraction task panicked: {e}"
2768            );
2769            Err((
2770                StatusCode::INTERNAL_SERVER_ERROR,
2771                Json(serde_json::json!({"error": "Archive extraction failed"})),
2772            )
2773                .into_response())
2774        }
2775    }
2776}
2777
2778/// If `staging` contains exactly one top-level directory, return its path
2779/// (the common case when the archive was created with `webkitRelativePath`).
2780/// Otherwise return `None`.
2781async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2782    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2783    let first = entries.next_entry().await.ok()??;
2784    if !first.path().is_dir() {
2785        return None;
2786    }
2787    if entries.next_entry().await.unwrap_or(None).is_some() {
2788        return None;
2789    }
2790    Some(first.path())
2791}
2792
2793/// Request body for `POST /api/upload-directory`.
2794///
2795/// Each entry carries a relative path (identical to the browser's
2796/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2797/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2798/// avoids pulling in a `multipart` library that is not in the vendor archive.
2799#[derive(Deserialize)]
2800struct UploadDirRequest {
2801    files: Vec<UploadedFile>,
2802    /// If provided, append this batch to an existing upload session instead of
2803    /// creating a new staging directory. Must be a plain UUID (no path separators).
2804    upload_id: Option<String>,
2805}
2806
2807#[derive(Deserialize)]
2808struct UploadedFile {
2809    /// `webkitRelativePath` value from the browser File object.
2810    path: String,
2811    /// Raw file bytes encoded as standard base64.
2812    content: String,
2813}
2814
2815/// POST /api/upload-directory
2816///
2817/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2818/// Saves all files to a temp staging directory preserving their relative paths,
2819/// then returns the server-side root directory path so the caller can populate
2820/// the scan-path field and run a normal analysis.
2821///
2822/// Only available in server mode; returns 404 in local mode (use the native
2823/// rfd dialog instead).
2824async fn upload_directory_handler(
2825    State(state): State<AppState>,
2826    Json(body): Json<UploadDirRequest>,
2827) -> Response {
2828    if !state.server_mode {
2829        return StatusCode::NOT_FOUND.into_response();
2830    }
2831    if let Err(resp) = validate_upload_dir_request(&body) {
2832        return resp;
2833    }
2834    // Reuse an existing staging dir when the client sends a continuation batch,
2835    // otherwise create a fresh one. Validate the id to prevent path traversal.
2836    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2837    match write_upload_files(&body.files, &staging, &upload_id).await {
2838        Ok((file_count, project_root)) => {
2839            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2840            Json(serde_json::json!({
2841                "tmp_path": scan_root.to_string_lossy(),
2842                "file_count": file_count,
2843                "upload_id": upload_id.clone()
2844            }))
2845            .into_response()
2846        }
2847        Err(resp) => resp,
2848    }
2849}
2850
2851/// Request body for `POST /api/upload-file`.
2852#[derive(Deserialize)]
2853struct UploadFileRequest {
2854    /// Original filename (used only to preserve the extension).
2855    filename: String,
2856    /// File bytes encoded as standard base64.
2857    content: String,
2858}
2859
2860/// POST /api/upload-file
2861///
2862/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2863/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2864/// Only available in server mode.
2865async fn upload_file_handler(
2866    State(state): State<AppState>,
2867    Json(body): Json<UploadFileRequest>,
2868) -> Response {
2869    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2870
2871    if !state.server_mode {
2872        return StatusCode::NOT_FOUND.into_response();
2873    }
2874
2875    let Ok(data) = base64::Engine::decode(
2876        &base64::engine::general_purpose::STANDARD,
2877        body.content.as_bytes(),
2878    ) else {
2879        return (
2880            StatusCode::BAD_REQUEST,
2881            Json(serde_json::json!({"error": "Invalid base64 content"})),
2882        )
2883            .into_response();
2884    };
2885
2886    if data.len() > MAX_FILE_BYTES {
2887        return (
2888            StatusCode::PAYLOAD_TOO_LARGE,
2889            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2890        )
2891            .into_response();
2892    }
2893
2894    // Sanitise: strip any directory component from the filename.
2895    let filename = std::path::Path::new(&body.filename)
2896        .file_name()
2897        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2898
2899    let upload_id = uuid::Uuid::new_v4();
2900    let staging = std::env::temp_dir()
2901        .join("oxide-sloc-uploads")
2902        .join(upload_id.to_string());
2903
2904    if tokio::fs::create_dir_all(&staging).await.is_err() {
2905        return (
2906            StatusCode::INTERNAL_SERVER_ERROR,
2907            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2908        )
2909            .into_response();
2910    }
2911
2912    let dest = staging.join(&filename);
2913    if tokio::fs::write(&dest, &data).await.is_err() {
2914        let _ = tokio::fs::remove_dir_all(&staging).await;
2915        return (
2916            StatusCode::INTERNAL_SERVER_ERROR,
2917            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2918        )
2919            .into_response();
2920    }
2921
2922    Json(serde_json::json!({
2923        "tmp_path": dest.to_string_lossy(),
2924        "upload_id": upload_id.to_string()
2925    }))
2926    .into_response()
2927}
2928
2929/// POST /api/upload-tarball
2930///
2931/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2932/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2933/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2934/// project root. The two size fields power the "Original / Compressed project size" display in the
2935/// web UI.
2936///
2937/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
2938/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
2939/// cap during decompression. The browser-side JS creates the archive one file at a time using
2940/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
2941/// project size.
2942/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2943/// decompressed. Wraps any `std::io::Read` source.
2944struct SizeLimitReader<R> {
2945    inner: R,
2946    remaining: u64,
2947}
2948impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2949    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2950        if self.remaining == 0 {
2951            return Err(std::io::Error::other("decompressed size limit exceeded"));
2952        }
2953        let n = self.inner.read(buf)?;
2954        self.remaining = self.remaining.saturating_sub(n as u64);
2955        Ok(n)
2956    }
2957}
2958
2959async fn upload_tarball_handler(
2960    State(state): State<AppState>,
2961    request: axum::extract::Request,
2962) -> Response {
2963    if !state.server_mode {
2964        return StatusCode::NOT_FOUND.into_response();
2965    }
2966
2967    let upload_id = uuid::Uuid::new_v4().to_string();
2968    let upload_base = upload_base_dir();
2969    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2970    let staging = upload_staging_path(&upload_id);
2971    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2972
2973    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2974        tracing::error!(
2975            event = "upload_io_error",
2976            "failed to create upload base dir: {e}"
2977        );
2978        return (
2979            StatusCode::INTERNAL_SERVER_ERROR,
2980            Json(serde_json::json!({"error": "Upload initialization failed"})),
2981        )
2982            .into_response();
2983    }
2984
2985    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2986    let compressed_bytes =
2987        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2988            Ok(n) => n,
2989            Err(resp) => return resp,
2990        };
2991
2992    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2993    if let Err(resp) =
2994        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2995    {
2996        return resp;
2997    }
2998
2999    // ── 3. Find the project root inside the staging dir ───────────────────────
3000    // If the tar contained a single top-level directory (the common case when the
3001    // browser uses `webkitRelativePath`), return that as the scan root so the path
3002    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3003    let scan_root = find_single_top_dir(&staging)
3004        .await
3005        .unwrap_or_else(|| staging.clone());
3006
3007    // Compute original (uncompressed) size of the extracted tree.
3008    let original_bytes = tokio::task::spawn_blocking({
3009        let p = scan_root.clone();
3010        move || dir_size_bytes(&p)
3011    })
3012    .await
3013    .unwrap_or(0);
3014
3015    Json(serde_json::json!({
3016        "tmp_path": scan_root.to_string_lossy(),
3017        "upload_id": upload_id,
3018        "compressed_bytes": compressed_bytes,
3019        "original_bytes": original_bytes,
3020    }))
3021    .into_response()
3022}
3023
3024#[derive(Deserialize)]
3025struct LocateReportForm {
3026    file_path: String,
3027    #[serde(default)]
3028    redirect_url: Option<String>,
3029    #[serde(default)]
3030    expected_run_id: Option<String>,
3031}
3032
3033/// Render a view-reports error page and return it as a `Response`.
3034fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3035    let html = ErrorTemplate {
3036        message: message.into(),
3037        last_report_url: Some("/view-reports".to_string()),
3038        last_report_label: Some("View Reports".to_string()),
3039        run_id: None,
3040        error_code: None,
3041        csp_nonce: csp_nonce.to_owned(),
3042        version: env!("CARGO_PKG_VERSION"),
3043    }
3044    .render()
3045    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3046    Html(html).into_response()
3047}
3048
3049/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3050fn registry_entry_from_run(
3051    run: &AnalysisRun,
3052    json_path: PathBuf,
3053    html_path: PathBuf,
3054) -> RegistryEntry {
3055    let project_label = run.input_roots.first().map_or_else(
3056        || "Unknown Project".to_string(),
3057        |r| sanitize_project_label(r),
3058    );
3059    RegistryEntry {
3060        run_id: run.tool.run_id.clone(),
3061        timestamp_utc: run.tool.timestamp_utc,
3062        project_label,
3063        input_roots: run.input_roots.clone(),
3064        json_path: Some(json_path),
3065        html_path: Some(html_path),
3066        pdf_path: None,
3067        summary: ScanSummarySnapshot::from(&run.summary_totals),
3068        csv_path: None,
3069        xlsx_path: None,
3070        git_branch: None,
3071        git_commit: None,
3072        git_commit_long: None,
3073        git_author: None,
3074        git_tags: None,
3075        git_nearest_tag: None,
3076        git_commit_date: None,
3077    }
3078}
3079
3080/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3081/// immediately without requiring a server restart.
3082pub(crate) async fn register_artifacts_in_registry(
3083    state: &AppState,
3084    label: &str,
3085    run: &AnalysisRun,
3086    artifacts: &RunArtifacts,
3087) {
3088    let Some(json_path) = artifacts.json_path.clone() else {
3089        return;
3090    };
3091    let Some(html_path) = artifacts.html_path.clone() else {
3092        return;
3093    };
3094    let mut entry = registry_entry_from_run(run, json_path, html_path);
3095    entry.project_label = label.to_owned();
3096    let mut reg = state.registry.lock().await;
3097    reg.add_entry(entry);
3098    let _ = reg.save(&state.registry_path);
3099}
3100
3101fn is_html_report_file(p: &Path) -> bool {
3102    p.is_file()
3103        && p.extension()
3104            .and_then(|x| x.to_str())
3105            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3106        && p.file_name()
3107            .and_then(|n| n.to_str())
3108            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3109}
3110
3111fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3112    fs::read_dir(dir)
3113        .ok()?
3114        .flatten()
3115        .map(|e| e.path())
3116        .find(|p| is_html_report_file(p))
3117}
3118
3119fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3120    if let Some(f) = find_html_report_in_dir(dir) {
3121        return Some(f);
3122    }
3123    if let Ok(rd) = fs::read_dir(dir) {
3124        for entry in rd.flatten() {
3125            let sub = entry.path();
3126            if sub.is_dir() {
3127                if let Some(f) = find_html_report_in_dir(&sub) {
3128                    return Some(f);
3129                }
3130            }
3131        }
3132    }
3133    None
3134}
3135
3136/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3137/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3138///
3139/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3140#[allow(clippy::result_large_err)]
3141fn validate_locate_request(
3142    state: &AppState,
3143    file_path: &str,
3144    csp_nonce: &str,
3145) -> Result<(PathBuf, PathBuf), Response> {
3146    let raw = PathBuf::from(file_path);
3147
3148    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3149    let html_path = if raw.is_dir() {
3150        let found = find_html_report_in_tree(&raw);
3151        match found {
3152            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3153            None => {
3154                return Err(locate_report_error(
3155                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3156                     the folder that contains your scan output (result_*.html or report_*.html).",
3157                    csp_nonce,
3158                ));
3159            }
3160        }
3161    } else {
3162        let file_ext = raw
3163            .extension()
3164            .and_then(|e| e.to_str())
3165            .unwrap_or("")
3166            .to_ascii_lowercase();
3167        if file_ext != "html" {
3168            return Err(locate_report_error(
3169                "Please select the scan output folder, or an .html report file directly.",
3170                csp_nonce,
3171            ));
3172        }
3173        match fs::canonicalize(&raw) {
3174            Ok(p) => strip_unc_prefix(p),
3175            Err(_) => {
3176                return Err(locate_report_error(
3177                    "Report file not found or path is invalid.",
3178                    csp_nonce,
3179                ));
3180            }
3181        }
3182    };
3183
3184    if state.server_mode {
3185        let output_root = resolve_output_root(None);
3186        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3187        if !html_path.starts_with(&canonical_root) {
3188            return Err(locate_report_error(
3189                "Report file must be within the configured output directory.",
3190                csp_nonce,
3191            ));
3192        }
3193    }
3194    let parent = match html_path.parent() {
3195        Some(p) => p.to_path_buf(),
3196        None => {
3197            return Err(locate_report_error(
3198                "Report file has no parent directory.",
3199                csp_nonce,
3200            ));
3201        }
3202    };
3203    Ok((html_path, parent))
3204}
3205
3206/// JSON-or-HTML error for `locate_report_handler` error paths.
3207fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3208    if want_json {
3209        (
3210            StatusCode::UNPROCESSABLE_ENTITY,
3211            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3212        )
3213            .into_response()
3214    } else {
3215        locate_report_error(msg, csp_nonce)
3216    }
3217}
3218
3219/// JSON-or-redirect success for locate/relocate handler success paths.
3220fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3221    if want_json {
3222        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3223    } else {
3224        axum::response::Redirect::to(redirect).into_response()
3225    }
3226}
3227
3228/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3229/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3230fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3231    for jpath in candidates {
3232        if let Ok(run) = read_json(jpath) {
3233            if expected.is_empty() || run.tool.run_id == expected {
3234                return Some((jpath.clone(), run.tool.run_id));
3235            }
3236        }
3237    }
3238    None
3239}
3240
3241fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3242    html_path
3243        .parent()
3244        .and_then(|p| p.parent())
3245        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3246}
3247
3248fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3249    let mut hits = collect_result_json_candidates(scan_root);
3250    if hits.is_empty() {
3251        hits = collect_result_json_candidates(parent);
3252    }
3253    hits.sort();
3254    hits
3255}
3256
3257#[allow(clippy::too_many_lines)]
3258async fn locate_report_handler(
3259    State(state): State<AppState>,
3260    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3261    headers: axum::http::HeaderMap,
3262    Form(form): Form<LocateReportForm>,
3263) -> impl IntoResponse {
3264    let want_json = headers
3265        .get(axum::http::header::ACCEPT)
3266        .and_then(|v| v.to_str().ok())
3267        .is_some_and(|v| v.contains("application/json"));
3268
3269    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3270        Ok(v) => v,
3271        Err(resp) => {
3272            if want_json {
3273                return locate_handler_err(
3274                    true,
3275                    "No HTML report file found in the selected folder. \
3276                     Make sure you selected the folder that contains your \
3277                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3278                        .to_string(),
3279                    &csp_nonce,
3280                );
3281            }
3282            return resp;
3283        }
3284    };
3285
3286    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3287    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3288    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3289    let scan_root: &Path = &scan_root_owned;
3290    let json_candidates = gather_json_candidates(scan_root, &parent);
3291
3292    // If the expected_run_id was provided, find a JSON that matches it exactly.
3293    let expected_run_id = form
3294        .expected_run_id
3295        .as_deref()
3296        .unwrap_or("")
3297        .trim()
3298        .to_string();
3299
3300    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3301
3302    // If we have candidates but none matched the expected run_id, surface a clear error.
3303    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3304        let actual = json_candidates
3305            .iter()
3306            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3307            .unwrap_or_else(|| "unknown".to_string());
3308        return locate_handler_err(
3309            want_json,
3310            format!(
3311                "This folder contains a different scan.\n\n\
3312                 Expected run ID : {expected_run_id}\n\
3313                 Found run ID    : {actual}\n\n\
3314                 Please select the folder that contains the correct scan output."
3315            ),
3316            &csp_nonce,
3317        );
3318    }
3319
3320    let safe_redirect = form
3321        .redirect_url
3322        .as_deref()
3323        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3324        .unwrap_or("/view-reports?linked=1")
3325        .to_string();
3326
3327    let mut reg = state.registry.lock().await;
3328
3329    if let Some((json_path, run_id)) = matched_json {
3330        // Match by run_id in the registry (works even after files are moved).
3331        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3332            entry.html_path = Some(html_path);
3333            entry.json_path = Some(json_path);
3334            let _ = reg.save(&state.registry_path);
3335            drop(reg);
3336            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3337            state.artifacts.lock().await.remove(&run_id);
3338            return redirect_or_json_ok(want_json, &safe_redirect);
3339        }
3340        // No existing entry — build one from the JSON.
3341        match read_json(&json_path) {
3342            Ok(run) => {
3343                let entry = registry_entry_from_run(&run, json_path, html_path);
3344                reg.add_entry(entry);
3345                let _ = reg.save(&state.registry_path);
3346                drop(reg);
3347                state.artifacts.lock().await.remove(&run_id);
3348                return redirect_or_json_ok(want_json, &safe_redirect);
3349            }
3350            Err(e) => {
3351                drop(reg);
3352                return locate_handler_err(
3353                    want_json,
3354                    format!(
3355                        "Found the scan folder but could not parse the result JSON.\n\n\
3356                         The file may have been saved by an older version of OxideSLOC. \
3357                         Re-running the analysis will create a fresh, compatible record.\n\n\
3358                         Error: {e}"
3359                    ),
3360                    &csp_nonce,
3361                );
3362            }
3363        }
3364    }
3365
3366    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3367    if let Some(entry) = reg
3368        .entries
3369        .iter_mut()
3370        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3371    {
3372        entry.html_path = Some(html_path.clone());
3373        let _ = reg.save(&state.registry_path);
3374        drop(reg);
3375        state.artifacts.lock().await.remove(&expected_run_id);
3376        return redirect_or_json_ok(want_json, &safe_redirect);
3377    }
3378
3379    drop(reg);
3380    let hint = if state.server_mode {
3381        String::new()
3382    } else {
3383        format!(
3384            "\n\nSearched folder : {}\nHTML found      : {}",
3385            scan_root.display(),
3386            html_path.display()
3387        )
3388    };
3389    locate_handler_err(
3390        want_json,
3391        format!(
3392            "Could not link this report.\n\n\
3393             No result_*.json was found in the selected folder. \
3394             Make sure you selected the top-level scan output folder \
3395             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3396        ),
3397        &csp_nonce,
3398    )
3399}
3400
3401/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3402fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3403    fs::read_dir(dir)
3404        .ok()?
3405        .flatten()
3406        .map(|e| e.path())
3407        .find(|p| {
3408            p.is_file()
3409                && p.file_stem()
3410                    .and_then(|n| n.to_str())
3411                    .is_some_and(|n| n.starts_with("result"))
3412                && p.extension()
3413                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3414        })
3415}
3416
3417#[derive(Deserialize)]
3418struct LocateReportsDirForm {
3419    folder_path: String,
3420}
3421
3422#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3423async fn locate_reports_dir_handler(
3424    State(state): State<AppState>,
3425    Form(form): Form<LocateReportsDirForm>,
3426) -> impl IntoResponse {
3427    if state.server_mode {
3428        return StatusCode::NOT_FOUND.into_response();
3429    }
3430    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3431        Ok(p) => strip_unc_prefix(p),
3432        Err(_) => {
3433            return axum::response::Redirect::to(
3434                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3435            )
3436            .into_response();
3437        }
3438    };
3439    if !folder.is_dir() {
3440        return axum::response::Redirect::to(
3441            "/view-reports?error=Selected+path+is+not+a+directory.",
3442        )
3443        .into_response();
3444    }
3445
3446    let candidates = collect_result_json_candidates(&folder);
3447
3448    if candidates.is_empty() {
3449        return axum::response::Redirect::to(
3450            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3451        )
3452        .into_response();
3453    }
3454
3455    let mut linked_count: usize = 0;
3456    let mut reg = state.registry.lock().await;
3457    for json_path in candidates {
3458        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3459            continue;
3460        };
3461        if is_dir_already_registered(&reg, &parent) {
3462            continue;
3463        }
3464        let Some(entry) = build_registry_entry_from_json(json_path) else {
3465            continue;
3466        };
3467        reg.add_entry(entry);
3468        linked_count += 1;
3469    }
3470    let _ = reg.save(&state.registry_path);
3471    drop(reg);
3472
3473    if linked_count == 0 {
3474        return axum::response::Redirect::to(
3475            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3476        )
3477        .into_response();
3478    }
3479    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3480}
3481
3482#[derive(Deserialize)]
3483struct RelocateScanForm {
3484    run_id: String,
3485    folder_path: String,
3486    redirect_url: String,
3487}
3488
3489/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3490/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3491fn relocate_folder_err(
3492    want_json: bool,
3493    status: StatusCode,
3494    msg: &str,
3495    run_id: &str,
3496    folder_hint: &str,
3497    redirect_url: &str,
3498    csp_nonce: &str,
3499) -> Response {
3500    if want_json {
3501        (
3502            status,
3503            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3504        )
3505            .into_response()
3506    } else {
3507        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3508    }
3509}
3510
3511#[allow(clippy::too_many_lines)]
3512async fn relocate_scan_handler(
3513    State(state): State<AppState>,
3514    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3515    headers: axum::http::HeaderMap,
3516    Form(form): Form<RelocateScanForm>,
3517) -> impl IntoResponse {
3518    let want_json = headers
3519        .get(axum::http::header::ACCEPT)
3520        .and_then(|v| v.to_str().ok())
3521        .is_some_and(|v| v.contains("application/json"));
3522    if state.server_mode {
3523        return StatusCode::NOT_FOUND.into_response();
3524    }
3525
3526    let run_id = form.run_id.trim().to_string();
3527    let redirect_url = form.redirect_url.trim().to_string();
3528
3529    let run_exists = {
3530        let reg = state.registry.lock().await;
3531        reg.find_by_run_id(&run_id).is_some()
3532    };
3533    if !run_exists {
3534        if want_json {
3535            return (
3536                StatusCode::NOT_FOUND,
3537                axum::Json(serde_json::json!({
3538                    "ok": false,
3539                    "message": format!("Run ID '{run_id}' not found in registry.")
3540                })),
3541            )
3542                .into_response();
3543        }
3544        let html = ErrorTemplate {
3545            message: format!("Run ID '{run_id}' not found in registry."),
3546            last_report_url: Some("/compare-scans".to_string()),
3547            last_report_label: Some("Compare Scans".to_string()),
3548            run_id: Some(run_id.clone()),
3549            error_code: Some(404),
3550            csp_nonce: csp_nonce.clone(),
3551            version: env!("CARGO_PKG_VERSION"),
3552        }
3553        .render()
3554        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3555        return Html(html).into_response();
3556    }
3557
3558    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3559        Ok(p) => strip_unc_prefix(p),
3560        Err(_) => {
3561            return relocate_folder_err(
3562                want_json,
3563                StatusCode::UNPROCESSABLE_ENTITY,
3564                "Folder not found or path is invalid.",
3565                &run_id,
3566                form.folder_path.trim(),
3567                &redirect_url,
3568                &csp_nonce,
3569            );
3570        }
3571    };
3572    if !folder.is_dir() {
3573        return relocate_folder_err(
3574            want_json,
3575            StatusCode::UNPROCESSABLE_ENTITY,
3576            "Selected path is not a directory.",
3577            &run_id,
3578            &folder.display().to_string(),
3579            &redirect_url,
3580            &csp_nonce,
3581        );
3582    }
3583
3584    let json_candidates = find_result_files_by_ext(&folder, "json");
3585    if json_candidates.is_empty() {
3586        let msg = format!(
3587            "No result JSON files found in the selected folder.\nSearched: {}",
3588            folder.display()
3589        );
3590        return relocate_folder_err(
3591            want_json,
3592            StatusCode::UNPROCESSABLE_ENTITY,
3593            &msg,
3594            &run_id,
3595            &folder.display().to_string(),
3596            &redirect_url,
3597            &csp_nonce,
3598        );
3599    }
3600
3601    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3602        let msg = format!(
3603            "No matching scan found in the selected folder.\n\
3604             The JSON files present do not contain run ID: {run_id}\n\
3605             Searched: {}",
3606            folder.display()
3607        );
3608        return relocate_folder_err(
3609            want_json,
3610            StatusCode::UNPROCESSABLE_ENTITY,
3611            &msg,
3612            &run_id,
3613            &folder.display().to_string(),
3614            &redirect_url,
3615            &csp_nonce,
3616        );
3617    };
3618
3619    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3620    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3621    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3622
3623    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3624        redirect_url
3625    } else {
3626        "/compare-scans".to_string()
3627    };
3628    redirect_or_json_ok(want_json, &safe_redirect)
3629}
3630
3631fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3632    let mut out = Vec::new();
3633    collect_scan_files_by_ext(folder, ext, &mut out);
3634    if let Ok(rd) = fs::read_dir(folder) {
3635        for entry in rd.flatten() {
3636            let sub = entry.path();
3637            if sub.is_dir() {
3638                collect_scan_files_by_ext(&sub, ext, &mut out);
3639            }
3640        }
3641    }
3642    out
3643}
3644
3645fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3646    let Ok(rd) = fs::read_dir(dir) else { return };
3647    for entry in rd.flatten() {
3648        let p = entry.path();
3649        if p.is_file()
3650            && p.file_stem()
3651                .and_then(|n| n.to_str())
3652                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3653            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3654        {
3655            out.push(p);
3656        }
3657    }
3658}
3659
3660fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3661    candidates
3662        .iter()
3663        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3664        .cloned()
3665}
3666
3667/// Return the best folder hint for the relocate page.
3668/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
3669/// point at the parent — the actual top-level output directory — so the user
3670/// selects the root folder rather than the subfolder.
3671fn output_folder_hint(json_path: &std::path::Path) -> String {
3672    let Some(direct_parent) = json_path.parent() else {
3673        return String::new();
3674    };
3675    let parent_name = direct_parent
3676        .file_name()
3677        .and_then(|n| n.to_str())
3678        .unwrap_or("");
3679    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3680        direct_parent.parent().map_or_else(
3681            || direct_parent.display().to_string(),
3682            |p| p.display().to_string(),
3683        )
3684    } else {
3685        direct_parent.display().to_string()
3686    }
3687}
3688
3689async fn update_run_file_paths(
3690    state: &AppState,
3691    run_id: &str,
3692    json_path: PathBuf,
3693    html_path: Option<PathBuf>,
3694    pdf_path: Option<PathBuf>,
3695) {
3696    {
3697        let mut reg = state.registry.lock().await;
3698        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3699            entry.json_path = Some(json_path.clone());
3700            if let Some(ref hp) = html_path {
3701                entry.html_path = Some(hp.clone());
3702            }
3703            if let Some(ref pp) = pdf_path {
3704                entry.pdf_path = Some(pp.clone());
3705            }
3706        }
3707        let _ = reg.save(&state.registry_path);
3708    }
3709    // Also patch the in-memory artifacts map so the result page picks up the
3710    // new paths without requiring a server restart.
3711    {
3712        let mut map = state.artifacts.lock().await;
3713        if let Some(arts) = map.get_mut(run_id) {
3714            arts.json_path = Some(json_path);
3715            if let Some(hp) = html_path {
3716                arts.html_path = Some(hp);
3717            }
3718            if let Some(pp) = pdf_path {
3719                arts.pdf_path = Some(pp);
3720            }
3721        }
3722    }
3723}
3724
3725fn missing_scan_relocate_response(
3726    message: &str,
3727    run_id: &str,
3728    folder_hint: &str,
3729    redirect_url: &str,
3730    server_mode: bool,
3731    csp_nonce: &str,
3732) -> axum::response::Response {
3733    let html = RelocateScanTemplate {
3734        message: message.to_string(),
3735        run_id: run_id.to_string(),
3736        folder_hint: folder_hint.to_string(),
3737        redirect_url: redirect_url.to_string(),
3738        server_mode,
3739        csp_nonce: csp_nonce.to_owned(),
3740        version: env!("CARGO_PKG_VERSION"),
3741    }
3742    .render()
3743    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3744    (StatusCode::NOT_FOUND, Html(html)).into_response()
3745}
3746
3747// ── Watched-directory helpers ─────────────────────────────────────────────────
3748
3749/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3750fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3751    fs::read_dir(dir)
3752        .ok()?
3753        .flatten()
3754        .map(|e| e.path())
3755        .find(|p| {
3756            p.is_file()
3757                && p.extension()
3758                    .and_then(|e| e.to_str())
3759                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3760        })
3761}
3762
3763/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
3764/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
3765/// (`<scan_dir>/json/result*.json`).
3766fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3767    let mut out = Vec::new();
3768    if let Some(j) = find_result_json_in_dir(sub) {
3769        out.push(j);
3770    }
3771    let json_sub = sub.join("json");
3772    if json_sub.is_dir() {
3773        if let Some(j) = find_result_json_in_dir(&json_sub) {
3774            out.push(j);
3775        }
3776    }
3777    out
3778}
3779
3780fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3781    let mut candidates = Vec::new();
3782    if let Some(j) = find_result_json_in_dir(folder) {
3783        candidates.push(j);
3784    }
3785    let Ok(dir_entries) = fs::read_dir(folder) else {
3786        return candidates;
3787    };
3788    for entry in dir_entries.flatten() {
3789        let sub = entry.path();
3790        if sub.is_dir() {
3791            candidates.extend(subdir_result_json_candidates(&sub));
3792        }
3793    }
3794    candidates
3795}
3796
3797fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3798    reg.entries.iter().any(|e| {
3799        let dir_match = e
3800            .json_path
3801            .as_ref()
3802            .and_then(|p| p.parent())
3803            .is_some_and(|p| p == parent)
3804            || e.html_path
3805                .as_ref()
3806                .and_then(|p| p.parent())
3807                .is_some_and(|p| p == parent);
3808        dir_match
3809            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3810                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3811    })
3812}
3813
3814fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3815    let json_dir = json_path.parent()?.to_path_buf();
3816    // If the JSON lives inside a directory named "json", the scan root is its parent
3817    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
3818    let (html_path, pdf_path, csv_path, xlsx_path) =
3819        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3820            let scan_root = json_dir.parent()?;
3821            let html = find_html_report_in_dir(&scan_root.join("html"))
3822                .or_else(|| find_html_report_in_dir(scan_root));
3823            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3824            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3825            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3826            (html, pdf, csv, xlsx)
3827        } else {
3828            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3829                rd.flatten()
3830                    .map(|e| e.path())
3831                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3832            });
3833            (html, None, None, None)
3834        };
3835    let run = read_json(&json_path).ok()?;
3836    let project_label = run.input_roots.first().map_or_else(
3837        || "Unknown Project".to_string(),
3838        |r| sanitize_project_label(r),
3839    );
3840    Some(RegistryEntry {
3841        run_id: run.tool.run_id.clone(),
3842        timestamp_utc: run.tool.timestamp_utc,
3843        project_label,
3844        input_roots: run.input_roots.clone(),
3845        json_path: Some(json_path),
3846        html_path,
3847        pdf_path,
3848        csv_path,
3849        xlsx_path,
3850        summary: ScanSummarySnapshot::from(&run.summary_totals),
3851        git_branch: run.git_branch.clone(),
3852        git_commit: run.git_commit_short.clone(),
3853        git_commit_long: run.git_commit_long.clone(),
3854        git_author: run.git_commit_author.clone(),
3855        git_tags: run.git_tags.clone(),
3856        git_nearest_tag: run.git_nearest_tag.clone(),
3857        git_commit_date: run.git_commit_date,
3858    })
3859}
3860
3861/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3862/// Returns the number of newly linked entries.
3863fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3864    let mut linked = 0usize;
3865    for json_path in collect_result_json_candidates(folder) {
3866        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3867            continue;
3868        };
3869        if is_dir_already_registered(reg, &parent) {
3870            continue;
3871        }
3872        let Some(entry) = build_registry_entry_from_json(json_path) else {
3873            continue;
3874        };
3875        reg.add_entry(entry);
3876        linked += 1;
3877    }
3878    linked
3879}
3880
3881/// Scan all watched directories (plus the default output root) into `reg`.
3882async fn auto_scan_watched_dirs(state: &AppState) {
3883    let dirs: Vec<PathBuf> = {
3884        let wd = state.watched_dirs.lock().await;
3885        wd.dirs.clone()
3886    };
3887    if dirs.is_empty() {
3888        return;
3889    }
3890    let mut reg = state.registry.lock().await;
3891    let mut total = 0usize;
3892    for dir in &dirs {
3893        if dir.is_dir() {
3894            total += scan_folder_into_registry(dir, &mut reg);
3895        }
3896    }
3897    if total > 0 {
3898        let _ = reg.save(&state.registry_path);
3899    }
3900}
3901
3902// ── Watched-dir route forms ───────────────────────────────────────────────────
3903
3904#[derive(Deserialize)]
3905struct WatchedDirForm {
3906    folder_path: String,
3907    #[serde(default = "default_redirect")]
3908    redirect_to: String,
3909}
3910
3911fn default_redirect() -> String {
3912    "/view-reports".to_string()
3913}
3914
3915#[derive(Deserialize)]
3916struct WatchedDirRefreshForm {
3917    #[serde(default = "default_redirect")]
3918    redirect_to: String,
3919}
3920
3921// ── Watched-dir helpers ───────────────────────────────────────────────────────
3922
3923/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3924fn safe_redirect(dest: &str) -> &str {
3925    if dest.starts_with('/') {
3926        dest
3927    } else {
3928        "/"
3929    }
3930}
3931
3932// ── Watched-dir handlers ──────────────────────────────────────────────────────
3933
3934async fn add_watched_dir_handler(
3935    State(state): State<AppState>,
3936    Form(form): Form<WatchedDirForm>,
3937) -> impl IntoResponse {
3938    if state.server_mode {
3939        return StatusCode::NOT_FOUND.into_response();
3940    }
3941    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3942        strip_unc_prefix(p)
3943    } else {
3944        let dest = format!(
3945            "{}?error=Folder+not+found+or+path+is+invalid.",
3946            safe_redirect(&form.redirect_to)
3947        );
3948        return axum::response::Redirect::to(&dest).into_response();
3949    };
3950    if !folder.is_dir() {
3951        let dest = format!(
3952            "{}?error=Selected+path+is+not+a+directory.",
3953            safe_redirect(&form.redirect_to)
3954        );
3955        return axum::response::Redirect::to(&dest).into_response();
3956    }
3957
3958    // Persist the watched directory.
3959    {
3960        let mut wd = state.watched_dirs.lock().await;
3961        wd.add(folder.clone());
3962        let _ = wd.save(&state.watched_dirs_path);
3963    }
3964
3965    // Immediately scan the folder and add any new reports.
3966    let linked = {
3967        let mut reg = state.registry.lock().await;
3968        let n = scan_folder_into_registry(&folder, &mut reg);
3969        if n > 0 {
3970            let _ = reg.save(&state.registry_path);
3971        }
3972        n
3973    };
3974
3975    let dest = if linked > 0 {
3976        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3977    } else {
3978        format!(
3979            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3980            safe_redirect(&form.redirect_to)
3981        )
3982    };
3983    axum::response::Redirect::to(&dest).into_response()
3984}
3985
3986async fn remove_watched_dir_handler(
3987    State(state): State<AppState>,
3988    Form(form): Form<WatchedDirForm>,
3989) -> impl IntoResponse {
3990    if state.server_mode {
3991        return StatusCode::NOT_FOUND.into_response();
3992    }
3993    let folder = PathBuf::from(&form.folder_path);
3994    {
3995        let mut wd = state.watched_dirs.lock().await;
3996        wd.remove(&folder);
3997        let _ = wd.save(&state.watched_dirs_path);
3998    }
3999    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4000}
4001
4002async fn refresh_watched_dirs_handler(
4003    State(state): State<AppState>,
4004    Form(form): Form<WatchedDirRefreshForm>,
4005) -> impl IntoResponse {
4006    if state.server_mode {
4007        return StatusCode::NOT_FOUND.into_response();
4008    }
4009    let dirs: Vec<PathBuf> = {
4010        let wd = state.watched_dirs.lock().await;
4011        wd.dirs.clone()
4012    };
4013    let mut total = 0usize;
4014    {
4015        let mut reg = state.registry.lock().await;
4016        reg.prune_stale();
4017        for dir in &dirs {
4018            if dir.is_dir() {
4019                total += scan_folder_into_registry(dir, &mut reg);
4020            }
4021        }
4022        let _ = reg.save(&state.registry_path);
4023    }
4024    let dest = if total > 0 {
4025        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4026    } else {
4027        safe_redirect(&form.redirect_to).to_owned()
4028    };
4029    axum::response::Redirect::to(&dest).into_response()
4030}
4031
4032#[derive(Debug, Deserialize)]
4033struct OpenPathQuery {
4034    path: Option<String>,
4035}
4036
4037fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4038    let mut ancestor = std::path::Path::new(raw);
4039    loop {
4040        match ancestor.parent() {
4041            Some(p) => {
4042                ancestor = p;
4043                if ancestor.is_dir() {
4044                    break;
4045                }
4046            }
4047            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4048        }
4049    }
4050    Ok(ancestor.to_path_buf())
4051}
4052
4053async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4054    match tokio::fs::canonicalize(raw).await {
4055        Ok(canonical) if canonical.is_file() => canonical
4056            .parent()
4057            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4058                Ok(p.to_path_buf())
4059            }),
4060        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4061        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4062        Err(_) => find_existing_ancestor(raw),
4063    }
4064}
4065
4066async fn open_path_handler(
4067    State(state): State<AppState>,
4068    Query(query): Query<OpenPathQuery>,
4069) -> impl IntoResponse {
4070    if state.server_mode {
4071        return Json(serde_json::json!({
4072            "server_mode_disabled": true,
4073            "message": "Opening a path in the file manager is only available in local desktop mode."
4074        }))
4075        .into_response();
4076    }
4077    // Skip the OS file-manager call in headless / CI environments.
4078    if std::env::var("SLOC_HEADLESS").is_ok() {
4079        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4080    }
4081    let raw = match query.path.as_deref() {
4082        Some(p) if !p.is_empty() => p,
4083        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4084    };
4085
4086    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4087    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4088    // so the file explorer still opens somewhere useful.
4089    let target = match resolve_open_target(raw).await {
4090        Ok(p) => p,
4091        Err((code, msg)) => return (code, msg).into_response(),
4092    };
4093
4094    #[cfg(target_os = "windows")]
4095    win_dialog_focus::open_folder_foreground(target);
4096    #[cfg(target_os = "macos")]
4097    let _ = std::process::Command::new("open")
4098        .arg(&target)
4099        .stdout(Stdio::null())
4100        .stderr(Stdio::null())
4101        .spawn();
4102    #[cfg(target_os = "linux")]
4103    {
4104        let folder_name = target
4105            .file_name()
4106            .and_then(|n| n.to_str())
4107            .map(str::to_owned);
4108        let _ = std::process::Command::new("xdg-open")
4109            .arg(&target)
4110            .stdout(Stdio::null())
4111            .stderr(Stdio::null())
4112            .spawn();
4113        // Best-effort: raise the file manager window once it appears.
4114        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4115        // installed; failures are silently discarded.
4116        if let Some(name) = folder_name {
4117            std::thread::spawn(move || {
4118                std::thread::sleep(std::time::Duration::from_millis(800));
4119                let _ = std::process::Command::new("wmctrl")
4120                    .args(["-a", &name])
4121                    .stdout(Stdio::null())
4122                    .stderr(Stdio::null())
4123                    .spawn();
4124            });
4125        }
4126    }
4127
4128    Json(serde_json::json!({"ok": true})).into_response()
4129}
4130
4131async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4132    let (content_type, bytes): (&'static str, &'static [u8]) =
4133        match (folder.as_str(), file.as_str()) {
4134            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4135            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4136            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4137            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4138            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4139            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4140            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4141            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4142            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4143            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4144            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4145            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4146            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4147            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4148            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4149            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4150            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4151            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4152            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4153            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4154            _ => return StatusCode::NOT_FOUND.into_response(),
4155        };
4156    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4157}
4158
4159/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4160/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4161/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4162/// complexity low; the fail-closed semantics are unchanged.
4163fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4164    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4165    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4166    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4167    // sample/upload locations are permitted; everything else is rejected.
4168    let Ok(canonical) = fs::canonicalize(resolved) else {
4169        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4170            return Err(Html(
4171                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4172            ));
4173        }
4174        return Ok(());
4175    };
4176    // Upload temp dirs and built-in sample/fixture paths are always safe.
4177    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4178        return Ok(());
4179    }
4180    let config = &state.base_config;
4181    if config.discovery.allowed_scan_roots.is_empty() {
4182        return Err(Html(
4183            r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4184        ));
4185    }
4186    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4187        fs::canonicalize(root)
4188            .ok()
4189            .is_some_and(|r| canonical.starts_with(&r))
4190    });
4191    if !allowed {
4192        return Err(Html(
4193            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4194        ));
4195    }
4196    Ok(())
4197}
4198
4199async fn preview_handler(
4200    State(state): State<AppState>,
4201    Query(query): Query<PreviewQuery>,
4202) -> impl IntoResponse {
4203    let raw_path = query
4204        .path
4205        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
4206    let resolved = resolve_input_path(&raw_path);
4207
4208    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4209    // binary whose working directory is not the project root), return a clear message
4210    // instead of an opaque OS error from build_preview_html.
4211    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4212        return Html(
4213            r#"<div class="preview-error">Sample directory not available on this server.
4214            Enter a path to a project directory or upload files using Browse.</div>"#
4215                .to_string(),
4216        );
4217    }
4218
4219    if state.server_mode {
4220        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4221            return resp;
4222        }
4223    }
4224
4225    let include_patterns = split_patterns(query.include_globs.as_deref());
4226    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4227
4228    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4229        Ok(html) => Html(html),
4230        Err(err) => Html(format!(
4231            r#"<div class="preview-error">Preview failed: {}</div>"#,
4232            escape_html(&err.to_string())
4233        )),
4234    }
4235}
4236
4237#[derive(Debug, Deserialize, Default)]
4238struct SuggestCoverageQuery {
4239    path: Option<String>,
4240}
4241
4242#[derive(Serialize)]
4243struct SuggestCoverageResponse {
4244    found: Option<String>,
4245    tool: Option<&'static str>,
4246    hint: Option<&'static str>,
4247}
4248
4249async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4250    const CANDIDATES: &[&str] = &[
4251        // LCOV — cargo-llvm-cov, gcov, lcov
4252        "coverage/lcov.info",
4253        "lcov.info",
4254        "target/llvm-cov/lcov.info",
4255        "target/coverage/lcov.info",
4256        "target/debug/coverage/lcov.info",
4257        "coverage/coverage.lcov",
4258        "build/coverage/lcov.info",
4259        "reports/lcov.info",
4260        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4261        "coverage.xml",
4262        "coverage/coverage.xml",
4263        "target/site/cobertura/coverage.xml",
4264        "build/reports/coverage/coverage.xml",
4265        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4266        "target/site/jacoco/jacoco.xml",
4267        "build/reports/jacoco/test/jacocoTestReport.xml",
4268        "build/reports/jacoco/jacocoTestReport.xml",
4269        "build/jacoco/jacoco.xml",
4270        // coverage.py native JSON — `coverage json`
4271        "coverage.json",
4272        "coverage/coverage.json",
4273    ];
4274    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4275    let found = CANDIDATES
4276        .iter()
4277        .map(|rel| root.join(rel))
4278        .find(|p| p.is_file())
4279        .map(|p| display_path(&p));
4280
4281    let (tool, hint) = detect_coverage_tool(&root);
4282    Json(SuggestCoverageResponse { found, tool, hint })
4283}
4284
4285/// Inspect the project root for known build/package files and return the most likely coverage
4286/// tool name and the shell command needed to generate a coverage file.
4287fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4288    if root.join("Cargo.toml").is_file() {
4289        return (
4290            Some("cargo-llvm-cov"),
4291            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4292        );
4293    }
4294    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4295        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4296    }
4297    if root.join("pom.xml").is_file() {
4298        return (Some("jacoco"), Some("mvn test jacoco:report"));
4299    }
4300    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4301        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4302    }
4303    (None, None)
4304}
4305
4306/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4307#[allow(clippy::result_large_err)]
4308fn validate_server_scan_path(
4309    config: &sloc_config::AppConfig,
4310    resolved_path: &Path,
4311    csp_nonce: &str,
4312) -> Result<(), Response> {
4313    if config.discovery.allowed_scan_roots.is_empty() {
4314        let template = ErrorTemplate {
4315            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4316                      Set allowed_scan_roots in the server config to permit scanning."
4317                .to_string(),
4318            last_report_url: None,
4319            last_report_label: None,
4320            run_id: None,
4321            error_code: Some(403),
4322            csp_nonce: csp_nonce.to_owned(),
4323            version: env!("CARGO_PKG_VERSION"),
4324        };
4325        return Err((
4326            StatusCode::FORBIDDEN,
4327            Html(
4328                template
4329                    .render()
4330                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4331            ),
4332        )
4333            .into_response());
4334    }
4335    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4336    // location) we must NOT fall back to the raw, un-normalised path — a textual
4337    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4338    // allowlist. A non-resolvable scan target is rejected outright.
4339    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4340        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4341            "Scan path does not resolve to a real location");
4342        let template = ErrorTemplate {
4343            message: "The requested path could not be resolved to a real directory.".to_string(),
4344            last_report_url: None,
4345            last_report_label: None,
4346            run_id: None,
4347            error_code: Some(403),
4348            csp_nonce: csp_nonce.to_owned(),
4349            version: env!("CARGO_PKG_VERSION"),
4350        };
4351        return Err((
4352            StatusCode::FORBIDDEN,
4353            Html(
4354                template
4355                    .render()
4356                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4357            ),
4358        )
4359            .into_response());
4360    };
4361    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4362        fs::canonicalize(root)
4363            .ok()
4364            .is_some_and(|r| canonical.starts_with(&r))
4365    });
4366    if !allowed {
4367        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4368            "Scan path not in allowed_scan_roots");
4369        let template = ErrorTemplate {
4370            message: "The requested path is not within an allowed scan directory.".to_string(),
4371            last_report_url: None,
4372            last_report_label: None,
4373            run_id: None,
4374            error_code: Some(403),
4375            csp_nonce: csp_nonce.to_owned(),
4376            version: env!("CARGO_PKG_VERSION"),
4377        };
4378        return Err((
4379            StatusCode::FORBIDDEN,
4380            Html(
4381                template
4382                    .render()
4383                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4384            ),
4385        )
4386            .into_response());
4387    }
4388    Ok(())
4389}
4390
4391/// Exclude the output directory from scanning so artifacts don't pollute counts.
4392fn apply_output_dir_exclusions(
4393    config: &mut sloc_config::AppConfig,
4394    project_path: &str,
4395    raw_output_dir: &str,
4396) {
4397    let project_root = resolve_input_path(project_path);
4398    let raw_out = raw_output_dir.trim();
4399    let resolved_out = if raw_out.is_empty() {
4400        project_root.join("sloc")
4401    } else if Path::new(raw_out).is_absolute() {
4402        PathBuf::from(raw_out)
4403    } else {
4404        workspace_root().join(raw_out)
4405    };
4406    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4407        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4408            let dir = first.to_string();
4409            if !config.discovery.excluded_directories.contains(&dir) {
4410                config.discovery.excluded_directories.push(dir);
4411            }
4412        }
4413    }
4414    if !config
4415        .discovery
4416        .excluded_directories
4417        .iter()
4418        .any(|d| d == "sloc")
4419    {
4420        config
4421            .discovery
4422            .excluded_directories
4423            .push("sloc".to_string());
4424    }
4425}
4426
4427/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4428const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4429    ScanSummarySnapshot {
4430        files_analyzed: run.summary_totals.files_analyzed,
4431        files_skipped: run.summary_totals.files_skipped,
4432        total_physical_lines: run.summary_totals.total_physical_lines,
4433        code_lines: run.summary_totals.code_lines,
4434        comment_lines: run.summary_totals.comment_lines,
4435        blank_lines: run.summary_totals.blank_lines,
4436        functions: run.summary_totals.functions,
4437        classes: run.summary_totals.classes,
4438        variables: run.summary_totals.variables,
4439        imports: run.summary_totals.imports,
4440        test_count: run.summary_totals.test_count,
4441        coverage_lines_found: run.summary_totals.coverage_lines_found,
4442        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4443        coverage_functions_found: run.summary_totals.coverage_functions_found,
4444        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4445        coverage_branches_found: run.summary_totals.coverage_branches_found,
4446        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4447    }
4448}
4449
4450/// Build the `RegistryEntry` for the just-completed scan run.
4451pub(crate) fn build_run_registry_entry(
4452    run: &AnalysisRun,
4453    run_id: &str,
4454    project_label: &str,
4455    artifacts: &RunArtifacts,
4456) -> RegistryEntry {
4457    RegistryEntry {
4458        run_id: run_id.to_owned(),
4459        timestamp_utc: run.tool.timestamp_utc,
4460        project_label: project_label.to_owned(),
4461        input_roots: run.input_roots.clone(),
4462        json_path: artifacts.json_path.clone(),
4463        html_path: artifacts.html_path.clone(),
4464        pdf_path: artifacts.pdf_path.clone(),
4465        csv_path: artifacts.csv_path.clone(),
4466        xlsx_path: artifacts.xlsx_path.clone(),
4467        summary: summary_snapshot_from_run(run),
4468        git_branch: run.git_branch.clone(),
4469        git_commit: run.git_commit_short.clone(),
4470        git_commit_long: run.git_commit_long.clone(),
4471        git_author: run.git_commit_author.clone(),
4472        git_tags: run.git_tags.clone(),
4473        git_nearest_tag: run.git_nearest_tag.clone(),
4474        git_commit_date: run.git_commit_date.clone(),
4475    }
4476}
4477
4478/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4479fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4480    if let Some(policy) = form.mixed_line_policy {
4481        config.analysis.mixed_line_policy = policy;
4482    }
4483    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4484    config.analysis.generated_file_detection =
4485        form.generated_file_detection.as_deref() != Some("disabled");
4486    config.analysis.minified_file_detection =
4487        form.minified_file_detection.as_deref() != Some("disabled");
4488    config.analysis.vendor_directory_detection =
4489        form.vendor_directory_detection.as_deref() != Some("disabled");
4490    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4491    if let Some(binary_behavior) = form.binary_file_behavior {
4492        config.analysis.binary_file_behavior = binary_behavior;
4493    }
4494    apply_report_opts(config, form);
4495    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4496    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4497    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4498    if let Some(policy) = form.continuation_line_policy {
4499        config.analysis.continuation_line_policy = policy;
4500    }
4501    if let Some(policy) = form.blank_in_block_comment_policy {
4502        config.analysis.blank_in_block_comment_policy = policy;
4503    }
4504    config.analysis.count_compiler_directives =
4505        form.count_compiler_directives.as_deref() != Some("disabled");
4506    apply_style_threshold(config, form);
4507    apply_coverage_path(config, form);
4508}
4509
4510fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4511    if let Some(report_title) = form.report_title.as_deref() {
4512        let trimmed = report_title.trim();
4513        if !trimmed.is_empty() {
4514            config.reporting.report_title = trimmed.to_string();
4515        }
4516    }
4517    if let Some(hf) = form.report_header_footer.as_deref() {
4518        let trimmed = hf.trim();
4519        config.reporting.report_header_footer = if trimmed.is_empty() {
4520            None
4521        } else {
4522            Some(trimmed.to_string())
4523        };
4524    }
4525}
4526
4527fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4528    apply_style_col_threshold(config, form);
4529    apply_style_analysis_enabled(config, form);
4530    apply_style_score_threshold(config, form);
4531    apply_style_lang_scope(config, form);
4532    apply_activity_window(config, form);
4533}
4534
4535fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4536    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4537        if let Ok(t) = threshold_str.parse::<u16>() {
4538            if t == 80 || t == 100 || t == 120 {
4539                config.analysis.style_col_threshold = t;
4540            }
4541        }
4542    }
4543}
4544
4545fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4546    if let Some(v) = form.style_analysis_enabled.as_deref() {
4547        config.analysis.style_analysis_enabled = v != "disabled";
4548    }
4549}
4550
4551fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4552    if let Some(v) = form.style_score_threshold.as_deref() {
4553        if let Ok(t) = v.parse::<u8>() {
4554            config.analysis.style_score_threshold = t.min(100);
4555        }
4556    }
4557}
4558
4559fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4560    if let Some(v) = form.style_lang_scope.as_deref() {
4561        let scope = v.trim();
4562        if scope == "c_family" || scope == "all" {
4563            config.analysis.style_lang_scope = scope.to_string();
4564        }
4565    }
4566}
4567
4568fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4569    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
4570    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
4571    if let Some(w) = form.activity_window.as_deref() {
4572        let w = w.trim();
4573        if !w.is_empty() {
4574            if let Ok(days) = w.parse::<u32>() {
4575                config.analysis.activity_window_days = Some(days);
4576            }
4577        }
4578    }
4579}
4580
4581fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4582    if let Some(cov) = &form.coverage_file {
4583        let trimmed = cov.trim();
4584        if !trimmed.is_empty() {
4585            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4586        }
4587    }
4588}
4589
4590/// Fire-and-forget: generate the PDF in a background task if one is pending.
4591/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4592/// an error instead of spinning indefinitely.
4593fn spawn_pdf_background(
4594    pending_pdf: PendingPdf,
4595    run_id: String,
4596    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4597) {
4598    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4599        tokio::spawn(async move {
4600            let result = tokio::task::spawn_blocking(move || {
4601                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4602                if cleanup_src {
4603                    let _ = fs::remove_file(&pdf_src);
4604                }
4605                r
4606            })
4607            .await;
4608            let failed = match result {
4609                Ok(Ok(())) => false,
4610                Ok(Err(err)) => {
4611                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4612                    true
4613                }
4614                Err(err) => {
4615                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4616                    true
4617                }
4618            };
4619            if failed {
4620                let mut map = artifacts.lock().await;
4621                if let Some(entry) = map.get_mut(&run_id) {
4622                    entry.pdf_path = None;
4623                }
4624            }
4625        });
4626    }
4627}
4628
4629/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4630/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4631/// result page can show an error on the next visit instead of spinning indefinitely.
4632fn spawn_native_pdf_background(
4633    json_path: PathBuf,
4634    pdf_dest: PathBuf,
4635    run_id: String,
4636    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4637) {
4638    tokio::spawn(async move {
4639        let result = tokio::task::spawn_blocking(move || {
4640            let run = sloc_core::read_json(&json_path)?;
4641            write_pdf_from_run(&run, &pdf_dest)
4642        })
4643        .await;
4644        let failed = match result {
4645            Ok(Ok(())) => false,
4646            Ok(Err(err)) => {
4647                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4648                true
4649            }
4650            Err(err) => {
4651                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4652                true
4653            }
4654        };
4655        if failed {
4656            let mut map = artifacts.lock().await;
4657            if let Some(entry) = map.get_mut(&run_id) {
4658                entry.pdf_path = None;
4659            }
4660        }
4661    });
4662}
4663
4664/// Sum the code lines added in this comparison (new + grown files).
4665fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4666    cmp.file_deltas
4667        .iter()
4668        .map(|f| match f.status {
4669            FileChangeStatus::Added => f.current_code,
4670            FileChangeStatus::Modified => f.code_delta.max(0),
4671            _ => 0,
4672        })
4673        .sum()
4674}
4675
4676/// Sum the code lines removed in this comparison (deleted + shrunk files).
4677fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4678    cmp.file_deltas
4679        .iter()
4680        .map(|f| match f.status {
4681            FileChangeStatus::Removed => f.baseline_code,
4682            FileChangeStatus::Modified => (-f.code_delta).max(0),
4683            _ => 0,
4684        })
4685        .sum()
4686}
4687
4688/// Sum the code lines present in both scans without any change (Unchanged files).
4689fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4690    cmp.file_deltas
4691        .iter()
4692        .filter(|f| f.status == FileChangeStatus::Unchanged)
4693        .map(|f| f.current_code)
4694        .sum()
4695}
4696
4697/// Sum the code lines residing in files that were modified between the two scans.
4698fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4699    cmp.file_deltas
4700        .iter()
4701        .filter(|f| f.status == FileChangeStatus::Modified)
4702        .map(|f| f.current_code)
4703        .sum()
4704}
4705
4706/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4707fn build_submodule_row(
4708    s: &sloc_core::SubmoduleSummary,
4709    run: &AnalysisRun,
4710    run_id: &str,
4711    run_dir: &Path,
4712) -> SubmoduleRow {
4713    let safe = sanitize_project_label(&s.name);
4714    let artifact_key = format!("sub_{safe}");
4715    let pdf_artifact_key = format!("sub_{safe}_pdf");
4716    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4717        let parent_path = run
4718            .input_roots
4719            .first()
4720            .map_or("", std::string::String::as_str);
4721        let sub_run = build_sub_run(run, s, parent_path);
4722        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4723        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4724            .ok()
4725            .and_then(|sub_html| {
4726                let sub_dir = run_dir.join("submodules");
4727                let _ = fs::create_dir_all(&sub_dir);
4728                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4729                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4730                    // Pre-generate the sub-report PDF using the programmatic renderer
4731                    // so "View PDF" never needs to spawn Chrome for submodules.
4732                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4733                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4734                    Some(format!("/runs/{artifact_key}/{run_id}"))
4735                } else {
4736                    None
4737                }
4738            })
4739    } else {
4740        None
4741    };
4742    SubmoduleRow {
4743        name: s.name.clone(),
4744        relative_path: s.relative_path.clone(),
4745        files_analyzed: s.files_analyzed,
4746        code_lines: s.code_lines,
4747        comment_lines: s.comment_lines,
4748        blank_lines: s.blank_lines,
4749        total_physical_lines: s.total_physical_lines,
4750        html_url,
4751    }
4752}
4753
4754// Immediately returns a wait page and runs the analysis in a background tokio task.
4755// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4756#[allow(clippy::similar_names)]
4757#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4758#[allow(clippy::too_many_lines)]
4759async fn analyze_handler(
4760    State(state): State<AppState>,
4761    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4762    Form(form): Form<AnalyzeForm>,
4763) -> impl IntoResponse {
4764    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4765        let template = ErrorTemplate {
4766            message: format!(
4767                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4768             Please wait a moment and try again."
4769            ),
4770            last_report_url: None,
4771            last_report_label: None,
4772            run_id: None,
4773            error_code: Some(503),
4774            csp_nonce: csp_nonce.clone(),
4775            version: env!("CARGO_PKG_VERSION"),
4776        };
4777        return (
4778            StatusCode::SERVICE_UNAVAILABLE,
4779            Html(
4780                template
4781                    .render()
4782                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4783            ),
4784        )
4785            .into_response();
4786    };
4787
4788    let mut config = state.base_config.clone();
4789
4790    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4791    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4792    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4793
4794    if !is_git_mode {
4795        let resolved_path = resolve_input_path(&form.path);
4796        if state.server_mode
4797            && !is_upload_tmp_path(&resolved_path)
4798            && !is_sample_path(&resolved_path)
4799        {
4800            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4801                return resp;
4802            }
4803        }
4804        config.discovery.root_paths = vec![resolved_path];
4805    }
4806
4807    apply_form_to_config(&mut config, &form);
4808    apply_output_dir_exclusions(
4809        &mut config,
4810        &form.path,
4811        form.output_dir.as_deref().unwrap_or(""),
4812    );
4813
4814    // Generate a wait_id now (before spawning) so the client can poll for status.
4815    let wait_id = uuid::Uuid::new_v4().to_string();
4816    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4817
4818    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4819    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4820    let task_cancel = Arc::clone(&cancel_token);
4821
4822    // Phase tracker: updated by run_analysis_task at key checkpoints.
4823    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4824    let task_phase = Arc::clone(&phase);
4825
4826    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4827    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4828    let task_files_done = Arc::clone(&files_done);
4829    let task_files_total = Arc::clone(&files_total);
4830
4831    // Register Running state before building the task struct so the semaphore permit
4832    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4833    {
4834        let mut runs = state.async_runs.lock().await;
4835        runs.insert(
4836            wait_id.clone(),
4837            AsyncRunState::Running {
4838                started_at: std::time::Instant::now(),
4839                cancel_token,
4840                phase,
4841                files_done,
4842                files_total,
4843            },
4844        );
4845    }
4846
4847    let task = AnalysisTask {
4848        sem_permit,
4849        state: state.clone(),
4850        wait_id: wait_id.clone(),
4851        config,
4852        cancel: task_cancel,
4853        phase: task_phase,
4854        files_done: task_files_done,
4855        files_total: task_files_total,
4856        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4857        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4858        project_path: form.path.clone(),
4859        // In server mode the client-supplied output_dir is ignored — artifacts are
4860        // always written under the server's configured output root so remote users
4861        // cannot direct writes to arbitrary filesystem paths.
4862        output_dir: if state.server_mode {
4863            None
4864        } else {
4865            form.output_dir.clone()
4866        },
4867        clones_dir: state.git_clones_dir.clone(),
4868        cocomo_mode: form
4869            .cocomo_mode
4870            .clone()
4871            .unwrap_or_else(|| "organic".to_string()),
4872        complexity_alert: form
4873            .complexity_alert
4874            .as_deref()
4875            .and_then(|s| s.parse::<u32>().ok())
4876            .unwrap_or(0),
4877        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4878    };
4879
4880    tokio::spawn(run_analysis_task(task));
4881
4882    let template = ScanWaitTemplate {
4883        version: env!("CARGO_PKG_VERSION"),
4884        wait_id_json,
4885        project_path: form.path.clone(),
4886        csp_nonce,
4887    };
4888    let html = template
4889        .render()
4890        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4891    let mut response = Html(html).into_response();
4892    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4893        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4894            response.headers_mut().insert(name, val);
4895        }
4896    }
4897    response
4898}
4899
4900struct AnalysisTask {
4901    sem_permit: tokio::sync::OwnedSemaphorePermit,
4902    state: AppState,
4903    wait_id: String,
4904    config: AppConfig,
4905    cancel: Arc<std::sync::atomic::AtomicBool>,
4906    phase: Arc<std::sync::Mutex<String>>,
4907    files_done: Arc<std::sync::atomic::AtomicUsize>,
4908    files_total: Arc<std::sync::atomic::AtomicUsize>,
4909    git_repo: Option<String>,
4910    git_ref: Option<String>,
4911    project_path: String,
4912    output_dir: Option<String>,
4913    clones_dir: PathBuf,
4914    cocomo_mode: String,
4915    complexity_alert: u32,
4916    exclude_duplicates: bool,
4917}
4918
4919#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4920async fn run_analysis_task(task: AnalysisTask) {
4921    let _permit = task.sem_permit;
4922
4923    let cancel_sb = Arc::clone(&task.cancel);
4924    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4925    let clones_dir_sb = task.clones_dir;
4926    // Save the upload staging path before config is moved into spawn_blocking.
4927    let upload_staging_root = task
4928        .config
4929        .discovery
4930        .root_paths
4931        .first()
4932        .filter(|p| is_upload_tmp_path(p))
4933        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4934        .map(PathBuf::from);
4935    let config_sb = task.config;
4936    let progress_sb = sloc_core::ProgressCounters {
4937        files_done: Arc::clone(&task.files_done),
4938        files_total: Arc::clone(&task.files_total),
4939    };
4940    if let Ok(mut p) = task.phase.lock() {
4941        *p = "Scanning files".to_string();
4942    }
4943    let analysis_result = tokio::task::spawn_blocking(move || {
4944        run_analysis_blocking(
4945            config_sb,
4946            git_repo_sb,
4947            git_ref_sb,
4948            clones_dir_sb,
4949            cancel_sb,
4950            Some(progress_sb),
4951        )
4952    })
4953    .await
4954    .map_err(|err| anyhow::anyhow!(err.to_string()))
4955    .and_then(|result| result);
4956
4957    if let Ok(mut p) = task.phase.lock() {
4958        *p = "Writing reports".to_string();
4959    }
4960
4961    // If cancelled while running, discard results and mark as cancelled.
4962    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4963        let mut runs = task.state.async_runs.lock().await;
4964        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4965        if matches!(
4966            runs.get(&task.wait_id),
4967            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4968        ) {
4969            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4970        }
4971        drop(runs);
4972        return;
4973    }
4974
4975    let run = match analysis_result {
4976        Ok(v) => v,
4977        Err(err) => {
4978            // Distinguish user-cancelled from real failure.
4979            if err.to_string().contains("analysis cancelled") {
4980                let mut runs = task.state.async_runs.lock().await;
4981                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4982                drop(runs);
4983                return;
4984            }
4985            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4986            let mut runs = task.state.async_runs.lock().await;
4987            runs.insert(
4988                task.wait_id.clone(),
4989                AsyncRunState::Failed {
4990                    message: "Analysis failed. Check that the path exists and is readable."
4991                        .to_string(),
4992                },
4993            );
4994            drop(runs);
4995            return;
4996        }
4997    };
4998
4999    let run_id = run.tool.run_id.clone();
5000    tracing::info!(event = "scan_complete", run_id = %run_id,
5001        path = %task.project_path, files = run.summary_totals.files_analyzed,
5002        "Analysis finished");
5003
5004    let prev_entry: Option<RegistryEntry> = {
5005        let reg = task.state.registry.lock().await;
5006        reg.entries_for_roots(&run.input_roots)
5007            .into_iter()
5008            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5009            .cloned()
5010    };
5011
5012    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5013        prev.json_path
5014            .as_ref()
5015            .and_then(|p| read_json(p).ok())
5016            .map(|prev_run| compute_delta(&prev_run, &run))
5017    });
5018    let prev_scan_count: usize = {
5019        let reg = task.state.registry.lock().await;
5020        reg.entries_for_roots(&run.input_roots)
5021            .iter()
5022            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5023            .count()
5024    };
5025
5026    // Build the HTML report now that delta is available, so the artifact
5027    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5028    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5029        .as_ref()
5030        .zip(prev_entry.as_ref())
5031        .map(|(cmp, prev)| ReportDeltaContext {
5032            delta_code_added: sum_added_code_lines(cmp),
5033            delta_code_removed: sum_removed_code_lines(cmp),
5034            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5035            delta_files_added: cmp.files_added,
5036            delta_files_removed: cmp.files_removed,
5037            delta_files_modified: cmp.files_modified,
5038            delta_files_unchanged: cmp.files_unchanged,
5039            prev_code_lines: prev.summary.code_lines,
5040            prev_scan_count: prev_scan_count + 1,
5041            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5042            prev_run_id: Some(prev.run_id.clone()),
5043            current_run_id: Some(run_id.clone()),
5044        });
5045    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5046        Ok(h) => h,
5047        Err(err) => {
5048            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5049            let mut runs = task.state.async_runs.lock().await;
5050            runs.insert(
5051                task.wait_id.clone(),
5052                AsyncRunState::Failed {
5053                    message: "Failed to render HTML report.".to_string(),
5054                },
5055            );
5056            drop(runs);
5057            return;
5058        }
5059    };
5060
5061    let output_root = resolve_output_root(task.output_dir.as_deref());
5062    let project_label = derive_project_label(
5063        task.git_repo.as_deref(),
5064        task.git_ref.as_deref(),
5065        &task.project_path,
5066    );
5067    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5068    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5069
5070    let result_context = RunResultContext {
5071        prev_entry: prev_entry.clone(),
5072        prev_scan_count,
5073        project_path: task.project_path.clone(),
5074        cocomo_mode: task.cocomo_mode.clone(),
5075        complexity_alert: task.complexity_alert,
5076        exclude_duplicates: task.exclude_duplicates,
5077    };
5078
5079    let artifact_result = persist_run_artifacts(
5080        &run,
5081        &report_html,
5082        &run_dir,
5083        &run.effective_configuration.reporting.report_title,
5084        &file_stem,
5085        result_context,
5086    );
5087
5088    let (artifacts, pending_pdf) = match artifact_result {
5089        Ok(v) => v,
5090        Err(err) => {
5091            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5092            let mut runs = task.state.async_runs.lock().await;
5093            runs.insert(
5094                task.wait_id.clone(),
5095                AsyncRunState::Failed {
5096                    message: "Failed to save report artifacts. Check available disk space."
5097                        .to_string(),
5098                },
5099            );
5100            drop(runs);
5101            return;
5102        }
5103    };
5104
5105    {
5106        let mut map = task.state.artifacts.lock().await;
5107        map.insert(run_id.clone(), artifacts.clone());
5108    }
5109
5110    {
5111        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5112        let mut reg = task.state.registry.lock().await;
5113        reg.add_entry(entry);
5114        let _ = reg.save(&task.state.registry_path);
5115    }
5116
5117    if let Some(ref cfg_path) = artifacts.scan_config_path {
5118        save_scan_config_json(
5119            cfg_path,
5120            &run,
5121            &task.project_path,
5122            task.output_dir.as_deref(),
5123            &task.cocomo_mode,
5124            task.complexity_alert,
5125            task.exclude_duplicates,
5126        );
5127    }
5128
5129    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5130
5131    prom_runs_total().inc();
5132
5133    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5134    let mut runs = task.state.async_runs.lock().await;
5135    runs.insert(
5136        task.wait_id.clone(),
5137        AsyncRunState::Complete {
5138            run_id: run_id.clone(),
5139        },
5140    );
5141    drop(runs);
5142
5143    // Remove the client-upload staging directory after a successful scan so
5144    // that uploaded project files don't accumulate in the OS temp directory.
5145    if let Some(staging) = upload_staging_root {
5146        let _ = tokio::fs::remove_dir_all(staging).await;
5147    }
5148
5149    let _ = scan_delta;
5150}
5151
5152fn save_scan_config_json(
5153    cfg_path: &std::path::Path,
5154    run: &sloc_core::AnalysisRun,
5155    project_path: &str,
5156    output_dir: Option<&str>,
5157    cocomo_mode: &str,
5158    complexity_alert: u32,
5159    exclude_duplicates: bool,
5160) {
5161    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5162        .ok()
5163        .and_then(|v| v.as_str().map(String::from))
5164        .unwrap_or_else(|| "code_only".to_string());
5165    let behavior_str =
5166        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5167            .ok()
5168            .and_then(|v| v.as_str().map(String::from))
5169            .unwrap_or_else(|| "skip".to_string());
5170    let continuation_policy_str = serde_json::to_value(
5171        run.effective_configuration
5172            .analysis
5173            .continuation_line_policy,
5174    )
5175    .ok()
5176    .and_then(|v| v.as_str().map(String::from))
5177    .unwrap_or_else(default_each_physical_line);
5178    let blank_policy_str = serde_json::to_value(
5179        run.effective_configuration
5180            .analysis
5181            .blank_in_block_comment_policy,
5182    )
5183    .ok()
5184    .and_then(|v| v.as_str().map(String::from))
5185    .unwrap_or_else(default_count_as_comment);
5186    let scan_cfg = ScanConfig {
5187        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5188        path: project_path.to_string(),
5189        include_globs: run
5190            .effective_configuration
5191            .discovery
5192            .include_globs
5193            .join("\n"),
5194        exclude_globs: run
5195            .effective_configuration
5196            .discovery
5197            .exclude_globs
5198            .join("\n"),
5199        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5200        mixed_line_policy: policy_str,
5201        python_docstrings_as_comments: run
5202            .effective_configuration
5203            .analysis
5204            .python_docstrings_as_comments,
5205        generated_file_detection: run
5206            .effective_configuration
5207            .analysis
5208            .generated_file_detection,
5209        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5210        vendor_directory_detection: run
5211            .effective_configuration
5212            .analysis
5213            .vendor_directory_detection,
5214        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5215        binary_file_behavior: behavior_str,
5216        output_dir: output_dir.unwrap_or("").to_string(),
5217        report_title: run.effective_configuration.reporting.report_title.clone(),
5218        continuation_line_policy: continuation_policy_str,
5219        blank_in_block_comment_policy: blank_policy_str,
5220        count_compiler_directives: run
5221            .effective_configuration
5222            .analysis
5223            .count_compiler_directives,
5224        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5225        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5226        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5227        style_lang_scope: run
5228            .effective_configuration
5229            .analysis
5230            .style_lang_scope
5231            .clone(),
5232        coverage_file: run
5233            .effective_configuration
5234            .analysis
5235            .coverage_file
5236            .as_ref()
5237            .map(|p| p.display().to_string())
5238            .unwrap_or_default(),
5239        cocomo_mode: cocomo_mode.to_string(),
5240        complexity_alert,
5241        exclude_duplicates,
5242        activity_window: run
5243            .effective_configuration
5244            .analysis
5245            .activity_window_days
5246            .unwrap_or(0),
5247    };
5248    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5249        let _ = std::fs::write(cfg_path, json);
5250    }
5251}
5252
5253#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5254fn run_analysis_blocking(
5255    mut config: AppConfig,
5256    git_repo: Option<String>,
5257    git_ref: Option<String>,
5258    clones_dir: PathBuf,
5259    cancel: Arc<std::sync::atomic::AtomicBool>,
5260    progress: Option<sloc_core::ProgressCounters>,
5261) -> Result<sloc_core::AnalysisRun> {
5262    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5263        let dest = git_clone_dest(&repo, &clones_dir);
5264        sloc_git::clone_or_fetch(&repo, &dest)?;
5265        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5266        sloc_git::create_worktree(&dest, &refname, &wt)?;
5267        config.discovery.root_paths = vec![wt.clone()];
5268        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5269        let _ = sloc_git::destroy_worktree(&dest, &wt);
5270        let mut run = run?;
5271        if run.git_branch.is_none() {
5272            run.git_branch = Some(refname);
5273        }
5274        return Ok(run);
5275    }
5276    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5277}
5278
5279fn derive_project_label(
5280    git_repo: Option<&str>,
5281    git_ref: Option<&str>,
5282    fallback_path: &str,
5283) -> String {
5284    match (
5285        git_repo.filter(|s| !s.is_empty()),
5286        git_ref.filter(|s| !s.is_empty()),
5287    ) {
5288        (Some(repo), Some(refname)) => {
5289            let repo_name = repo
5290                .trim_end_matches('/')
5291                .trim_end_matches(".git")
5292                .rsplit('/')
5293                .next()
5294                .unwrap_or("repo");
5295            sanitize_project_label(&format!("{repo_name}_{refname}"))
5296        }
5297        _ => sanitize_project_label(fallback_path),
5298    }
5299}
5300
5301fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5302    let commit = commit_short.unwrap_or("").trim();
5303    if commit.is_empty() {
5304        project_label.to_string()
5305    } else {
5306        format!("{project_label}_{commit}")
5307    }
5308}
5309
5310// ── Async scan status + result handlers ──────────────────────────────────────
5311
5312#[derive(Serialize)]
5313#[serde(tag = "state", rename_all = "snake_case")]
5314enum AsyncRunStatusResponse {
5315    Running {
5316        elapsed_secs: u64,
5317        phase: String,
5318        files_done: u64,
5319        files_total: u64,
5320    },
5321    Complete {
5322        run_id: String,
5323    },
5324    Failed {
5325        message: String,
5326    },
5327    Cancelled,
5328}
5329
5330async fn async_run_status_handler(
5331    State(state): State<AppState>,
5332    AxumPath(wait_id): AxumPath<String>,
5333) -> Response {
5334    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5335    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5336        return error::bad_request("invalid wait_id");
5337    }
5338    let run_state = {
5339        let runs = state.async_runs.lock().await;
5340        runs.get(&wait_id).cloned()
5341    };
5342    match run_state {
5343        None => error::not_found("run not found"),
5344        Some(AsyncRunState::Running {
5345            started_at,
5346            phase,
5347            files_done,
5348            files_total,
5349            ..
5350        }) => {
5351            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5352            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5353                let mut runs = state.async_runs.lock().await;
5354                runs.insert(
5355                    wait_id,
5356                    AsyncRunState::Failed {
5357                        message: "Analysis timed out after 2 hours.".to_string(),
5358                    },
5359                );
5360                drop(runs);
5361                return Json(AsyncRunStatusResponse::Failed {
5362                    message: "Analysis timed out after 2 hours.".to_string(),
5363                })
5364                .into_response();
5365            }
5366            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5367            Json(AsyncRunStatusResponse::Running {
5368                elapsed_secs: started_at.elapsed().as_secs(),
5369                phase: phase_str,
5370                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5371                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5372            })
5373            .into_response()
5374        }
5375        Some(AsyncRunState::Complete { run_id }) => {
5376            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5377        }
5378        Some(AsyncRunState::Failed { message }) => {
5379            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5380        }
5381        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5382    }
5383}
5384
5385async fn cancel_run_handler(
5386    State(state): State<AppState>,
5387    AxumPath(wait_id): AxumPath<String>,
5388) -> Response {
5389    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5390        return error::bad_request("invalid wait_id");
5391    }
5392    let mut runs = state.async_runs.lock().await;
5393    let resp = match runs.get(&wait_id) {
5394        Some(AsyncRunState::Running { cancel_token, .. }) => {
5395            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5396            runs.insert(wait_id, AsyncRunState::Cancelled);
5397            StatusCode::OK.into_response()
5398        }
5399        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5400        _ => error::not_found("run not found"),
5401    };
5402    drop(runs);
5403    resp
5404}
5405
5406async fn async_run_result_handler(
5407    State(state): State<AppState>,
5408    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5409    AxumPath(run_id): AxumPath<String>,
5410) -> Response {
5411    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5412        return StatusCode::BAD_REQUEST.into_response();
5413    }
5414
5415    let artifacts = {
5416        let map = state.artifacts.lock().await;
5417        map.get(&run_id).cloned()
5418    };
5419    let artifacts = if let Some(a) = artifacts {
5420        a
5421    } else {
5422        let reg = state.registry.lock().await;
5423        if let Some(entry) = reg.find_by_run_id(&run_id) {
5424            recover_artifacts_from_registry(entry)
5425        } else {
5426            let html = ErrorTemplate {
5427                message: format!(
5428                    "Report not found. Run ID {} is not in the scan history.",
5429                    &run_id[..run_id.len().min(8)]
5430                ),
5431                last_report_url: Some("/view-reports".to_string()),
5432                last_report_label: Some("View Reports".to_string()),
5433                run_id: Some(run_id.clone()),
5434                error_code: Some(404),
5435                csp_nonce: csp_nonce.clone(),
5436                version: env!("CARGO_PKG_VERSION"),
5437            }
5438            .render()
5439            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5440            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5441        }
5442    };
5443
5444    let json_path = if let Some(p) = &artifacts.json_path {
5445        p.clone()
5446    } else {
5447        let html = ErrorTemplate {
5448            message: "JSON result was not saved for this run.".to_string(),
5449            last_report_url: Some("/view-reports".to_string()),
5450            last_report_label: Some("View Reports".to_string()),
5451            run_id: Some(run_id.clone()),
5452            error_code: Some(404),
5453            csp_nonce: csp_nonce.clone(),
5454            version: env!("CARGO_PKG_VERSION"),
5455        }
5456        .render()
5457        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5458        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5459    };
5460
5461    let Ok(run) = read_json(&json_path) else {
5462        let folder_hint = output_folder_hint(&json_path);
5463        let redirect_url = format!("/runs/result/{run_id}");
5464        return missing_scan_relocate_response(
5465            &format!(
5466                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5467                 deleted. Browse to the folder containing your scan output to reconnect it.",
5468                json_path.display()
5469            ),
5470            &run_id,
5471            &folder_hint,
5472            &redirect_url,
5473            state.server_mode,
5474            &csp_nonce,
5475        );
5476    };
5477
5478    let confluence_configured = {
5479        let store = state.confluence.lock().await;
5480        store.is_configured()
5481    };
5482
5483    render_result_page(
5484        &run,
5485        &artifacts,
5486        &run_id,
5487        &csp_nonce,
5488        confluence_configured,
5489        state.server_mode,
5490    )
5491}
5492
5493/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5494fn json_escape(s: &str) -> String {
5495    s.replace('\\', "\\\\").replace('"', "\\\"")
5496}
5497
5498/// Per-language line/symbol totals summed across every language in a run.
5499struct LangTotals {
5500    physical_lines: u64,
5501    code_lines: u64,
5502    comment_lines: u64,
5503    blank_lines: u64,
5504    mixed_lines: u64,
5505    functions: u64,
5506    classes: u64,
5507    variables: u64,
5508    imports: u64,
5509}
5510
5511fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5512    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5513        run.totals_by_language.iter().map(f).sum()
5514    };
5515    LangTotals {
5516        physical_lines: s(|r| r.total_physical_lines),
5517        code_lines: s(|r| r.code_lines),
5518        comment_lines: s(|r| r.comment_lines),
5519        blank_lines: s(|r| r.blank_lines),
5520        mixed_lines: s(|r| r.mixed_lines_separate),
5521        functions: s(|r| r.functions),
5522        classes: s(|r| r.classes),
5523        variables: s(|r| r.variables),
5524        imports: s(|r| r.imports),
5525    }
5526}
5527
5528/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5529struct DeltaFields {
5530    prev_fa_str: String,
5531    prev_fs_str: String,
5532    prev_pl_str: String,
5533    prev_cl_str: String,
5534    prev_cml_str: String,
5535    prev_bl_str: String,
5536    delta_fa_str: String,
5537    delta_fa_class: String,
5538    delta_fs_str: String,
5539    delta_fs_class: String,
5540    delta_pl_str: String,
5541    delta_pl_class: String,
5542    delta_cl_str: String,
5543    delta_cl_class: String,
5544    delta_cml_str: String,
5545    delta_cml_class: String,
5546    delta_bl_str: String,
5547    delta_bl_class: String,
5548    delta_lines_added: Option<i64>,
5549    delta_lines_removed: Option<i64>,
5550    delta_lines_net_str: String,
5551    delta_lines_net_class: String,
5552}
5553
5554// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5555// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5556// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5557// from those field names and obscure the 1:1 mapping.
5558#[allow(
5559    clippy::similar_names,
5560    reason = "locals mirror template-bound struct fields"
5561)]
5562fn compute_delta_fields(
5563    prev_entry: Option<&RegistryEntry>,
5564    totals: &LangTotals,
5565    files_analyzed: u64,
5566    files_skipped: u64,
5567    scan_delta: Option<&sloc_core::ScanComparison>,
5568) -> DeltaFields {
5569    let prev_sum = prev_entry.map(|e| &e.summary);
5570    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5571
5572    let (delta_fa_str, delta_fa_class) =
5573        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5574    let (delta_fs_str, delta_fs_class) =
5575        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5576    let (delta_pl_str, delta_pl_class) = summary_delta(
5577        totals.physical_lines,
5578        prev_sum.map(|s| s.total_physical_lines),
5579    );
5580    let (delta_cl_str, delta_cl_class) =
5581        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5582    let (delta_cml_str, delta_cml_class) =
5583        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5584    let (delta_bl_str, delta_bl_class) =
5585        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5586
5587    let delta_lines_added = scan_delta.map(sum_added_code_lines);
5588    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5589    let (delta_lines_net_str, delta_lines_net_class) =
5590        match (delta_lines_added, delta_lines_removed) {
5591            (Some(a), Some(r)) => {
5592                let net = a - r;
5593                (fmt_delta(net), delta_class(net).to_string())
5594            }
5595            _ => ("\u{2014}".to_string(), "na".to_string()),
5596        };
5597
5598    DeltaFields {
5599        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5600        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5601        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5602        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5603        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5604        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5605        delta_fa_str,
5606        delta_fa_class: delta_fa_class.to_string(),
5607        delta_fs_str,
5608        delta_fs_class: delta_fs_class.to_string(),
5609        delta_pl_str,
5610        delta_pl_class: delta_pl_class.to_string(),
5611        delta_cl_str,
5612        delta_cl_class: delta_cl_class.to_string(),
5613        delta_cml_str,
5614        delta_cml_class: delta_cml_class.to_string(),
5615        delta_bl_str,
5616        delta_bl_class: delta_bl_class.to_string(),
5617        delta_lines_added,
5618        delta_lines_removed,
5619        delta_lines_net_str,
5620        delta_lines_net_class,
5621    }
5622}
5623
5624/// Count of unchanged code lines in a scan comparison.
5625fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5626    scan_delta
5627        .file_deltas
5628        .iter()
5629        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5630        .map(|f| {
5631            #[allow(clippy::cast_sign_loss)]
5632            let n = f.current_code as u64;
5633            n
5634        })
5635        .sum()
5636}
5637
5638fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5639    run.git_remote_url
5640        .as_deref()
5641        .zip(run.git_commit_long.as_deref())
5642        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5643}
5644
5645fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5646    run.git_remote_url
5647        .as_deref()
5648        .zip(run.git_branch.as_deref())
5649        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5650}
5651
5652fn scan_performed_by(run: &AnalysisRun) -> String {
5653    run.environment.ci_name.clone().unwrap_or_else(|| {
5654        format!(
5655            "{} / {}",
5656            run.environment.initiator_username, run.environment.initiator_hostname
5657        )
5658    })
5659}
5660
5661/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
5662fn build_lang_chart_json(run: &AnalysisRun) -> String {
5663    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5664    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5665    let entries: Vec<String> = langs
5666        .into_iter()
5667        .take(12)
5668        .map(|l| {
5669            let name = json_escape(l.language.display_name());
5670            format!(
5671                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5672                name,
5673                l.code_lines,
5674                l.comment_lines,
5675                l.blank_lines,
5676                l.total_physical_lines,
5677                l.functions,
5678                l.classes,
5679                l.variables,
5680                l.imports,
5681                l.files,
5682            )
5683        })
5684        .collect();
5685    format!("[{}]", entries.join(","))
5686}
5687
5688/// Per-language files-vs-lines points as a JSON array for the scatter chart.
5689fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5690    let entries: Vec<String> = run
5691        .totals_by_language
5692        .iter()
5693        .map(|l| {
5694            let name = json_escape(l.language.display_name());
5695            format!(
5696                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5697                name, l.files, l.code_lines, l.total_physical_lines,
5698            )
5699        })
5700        .collect();
5701    format!("[{}]", entries.join(","))
5702}
5703
5704/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
5705fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5706    let entries: Vec<String> = run
5707        .totals_by_language
5708        .iter()
5709        .filter(|l| {
5710            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5711        })
5712        .map(|l| {
5713            let name = json_escape(l.language.display_name());
5714            format!(
5715                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5716                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5717            )
5718        })
5719        .collect();
5720    format!("[{}]", entries.join(","))
5721}
5722
5723/// Per-submodule line counts as a JSON array for the submodule chart.
5724fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5725    let entries: Vec<String> = run
5726        .submodule_summaries
5727        .iter()
5728        .map(|s| {
5729            let name = json_escape(&s.name);
5730            format!(
5731                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5732                name,
5733                s.code_lines,
5734                s.comment_lines,
5735                s.blank_lines,
5736                s.total_physical_lines,
5737                s.files_analyzed,
5738            )
5739        })
5740        .collect();
5741    format!("[{}]", entries.join(","))
5742}
5743
5744/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
5745#[allow(clippy::cast_precision_loss)]
5746fn cov_pct_str(hit: u64, found: u64) -> String {
5747    if found > 0 {
5748        format!("{:.1}", hit as f64 / found as f64 * 100.0)
5749    } else {
5750        String::new()
5751    }
5752}
5753
5754/// `hit / found` summary string, or empty when nothing was found.
5755fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5756    if found > 0 {
5757        format!("{hit} / {found}")
5758    } else {
5759        String::new()
5760    }
5761}
5762
5763const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5764    use sloc_core::CocomoMode;
5765    match mode {
5766        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5767        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5768        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5769    }
5770}
5771
5772const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5773    use sloc_core::CocomoMode;
5774    match mode {
5775        CocomoMode::Organic => "Organic",
5776        CocomoMode::SemiDetached => "Semi-detached",
5777        CocomoMode::Embedded => "Embedded",
5778    }
5779}
5780
5781const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5782    use sloc_core::CocomoMode;
5783    match mode {
5784        CocomoMode::Organic => {
5785            "Organic: A small team working on a well-understood project in a familiar \
5786             environment with minimal external constraints. Suited for internal tools, \
5787             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5788        }
5789        CocomoMode::SemiDetached => {
5790            "Semi-detached: A mixed team with varying experience tackling a project with \
5791             moderate novelty and some rigid constraints. Typical for compilers, transaction \
5792             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5793        }
5794        CocomoMode::Embedded => {
5795            "Embedded: Tight hardware, software, or operational constraints requiring \
5796             significant innovation and deep integration work. Typical for real-time control \
5797             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5798        }
5799    }
5800}
5801
5802/// COCOMO display strings recomputed for the scan-wizard-selected mode.
5803struct CocomoFields {
5804    has_cocomo: bool,
5805    effort_str: String,
5806    duration_str: String,
5807    staff_str: String,
5808    ksloc_str: String,
5809    mode_label: String,
5810    mode_tooltip: String,
5811}
5812
5813#[allow(clippy::cast_precision_loss)]
5814fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5815    use sloc_core::CocomoMode;
5816    let mode = match mode_str {
5817        "semi_detached" => CocomoMode::SemiDetached,
5818        "embedded" => CocomoMode::Embedded,
5819        _ => CocomoMode::Organic,
5820    };
5821    let (a, b, c, d) = cocomo_coefficients(mode);
5822    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5823    let effort = a * ksloc.powf(b);
5824    let duration = c * effort.powf(d);
5825    let staff = if duration > 0.0 {
5826        effort / duration
5827    } else {
5828        0.0
5829    };
5830    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5831    let mode_label = cocomo_mode_label(mode).to_string();
5832    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5833    if run.summary_totals.code_lines > 0 {
5834        CocomoFields {
5835            has_cocomo: true,
5836            effort_str: round2(effort),
5837            duration_str: round2(duration),
5838            staff_str: round2(staff),
5839            ksloc_str: round2(ksloc),
5840            mode_label,
5841            mode_tooltip,
5842        }
5843    } else {
5844        CocomoFields {
5845            has_cocomo: false,
5846            effort_str: String::new(),
5847            duration_str: String::new(),
5848            staff_str: String::new(),
5849            ksloc_str: String::new(),
5850            mode_label,
5851            mode_tooltip,
5852        }
5853    }
5854}
5855
5856#[allow(clippy::too_many_lines)]
5857#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
5858#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
5859fn render_result_page(
5860    run: &AnalysisRun,
5861    artifacts: &RunArtifacts,
5862    run_id: &str,
5863    csp_nonce: &str,
5864    confluence_configured: bool,
5865    server_mode: bool,
5866) -> Response {
5867    let ctx = &artifacts.result_context;
5868    let prev_entry = &ctx.prev_entry;
5869    let prev_scan_count = ctx.prev_scan_count;
5870    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
5871    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
5872    // field is never blank.
5873    let project_path_owned = if ctx.project_path.is_empty() {
5874        run.input_roots.join(", ")
5875    } else {
5876        ctx.project_path.clone()
5877    };
5878    let project_path = &project_path_owned;
5879
5880    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5881        prev.json_path
5882            .as_ref()
5883            .and_then(|p| read_json(p).ok())
5884            .map(|prev_run| compute_delta(&prev_run, run))
5885    });
5886
5887    let files_analyzed = run.per_file_records.len() as u64;
5888    let files_skipped = run.skipped_file_records.len() as u64;
5889    let totals = sum_lang_totals(run);
5890
5891    let DeltaFields {
5892        prev_fa_str,
5893        prev_fs_str,
5894        prev_pl_str,
5895        prev_cl_str,
5896        prev_cml_str,
5897        prev_bl_str,
5898        delta_fa_str,
5899        delta_fa_class,
5900        delta_fs_str,
5901        delta_fs_class,
5902        delta_pl_str,
5903        delta_pl_class,
5904        delta_cl_str,
5905        delta_cl_class,
5906        delta_cml_str,
5907        delta_cml_class,
5908        delta_bl_str,
5909        delta_bl_class,
5910        delta_lines_added,
5911        delta_lines_removed,
5912        delta_lines_net_str,
5913        delta_lines_net_class,
5914    } = compute_delta_fields(
5915        prev_entry.as_ref(),
5916        &totals,
5917        files_analyzed,
5918        files_skipped,
5919        scan_delta.as_ref(),
5920    );
5921
5922    let run_dir = artifacts.output_dir.clone();
5923    let git_branch = run.git_branch.clone();
5924    let git_commit = run.git_commit_short.clone();
5925    let git_commit_long = run.git_commit_long.clone();
5926    let git_author = run.git_commit_author.clone();
5927    let git_commit_url = git_commit_url_for(run);
5928    let git_branch_url = git_branch_url_for(run);
5929    let scan_performed_by = scan_performed_by(run);
5930    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5931    let os_display = format!(
5932        "{} / {}",
5933        run.environment.operating_system, run.environment.architecture
5934    );
5935    let test_count = run.summary_totals.test_count;
5936
5937    // ── New metrics ──────────────────────────────────────────────────────────
5938    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5939    let lsloc = run.summary_totals.lsloc;
5940    let uloc = run.uloc;
5941    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5942    let duplicate_group_count = run.duplicate_groups.len();
5943
5944    // Re-compute COCOMO with the mode selected in the scan wizard.
5945    let ctx = &artifacts.result_context;
5946    let CocomoFields {
5947        has_cocomo,
5948        effort_str: cocomo_effort_str,
5949        duration_str: cocomo_duration_str,
5950        staff_str: cocomo_staff_str,
5951        ksloc_str: cocomo_ksloc_str,
5952        mode_label: cocomo_mode_label,
5953        mode_tooltip: cocomo_mode_tooltip,
5954    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
5955    let complexity_alert = ctx.complexity_alert;
5956
5957    let template = ResultTemplate {
5958        version: env!("CARGO_PKG_VERSION"),
5959        report_title: run.effective_configuration.reporting.report_title.clone(),
5960        project_path: project_path.clone(),
5961        output_dir: display_path(&artifacts.output_dir),
5962        run_id: run_id.to_owned(),
5963        run_id_short: run_id
5964            .split('-')
5965            .next_back()
5966            .unwrap_or(run_id)
5967            .chars()
5968            .take(7)
5969            .collect(),
5970        files_analyzed,
5971        files_skipped,
5972        physical_lines: totals.physical_lines,
5973        code_lines: totals.code_lines,
5974        comment_lines: totals.comment_lines,
5975        blank_lines: totals.blank_lines,
5976        mixed_lines: totals.mixed_lines,
5977        functions: totals.functions,
5978        classes: totals.classes,
5979        variables: totals.variables,
5980        imports: totals.imports,
5981        html_url: artifacts
5982            .html_path
5983            .as_ref()
5984            .map(|_| format!("/runs/html/{run_id}")),
5985        pdf_url: artifacts
5986            .pdf_path
5987            .as_ref()
5988            .map(|_| format!("/runs/pdf/{run_id}")),
5989        json_url: artifacts
5990            .json_path
5991            .as_ref()
5992            .map(|_| format!("/runs/json/{run_id}")),
5993        html_download_url: artifacts
5994            .html_path
5995            .as_ref()
5996            .map(|_| format!("/runs/html/{run_id}?download=1")),
5997        pdf_download_url: artifacts
5998            .pdf_path
5999            .as_ref()
6000            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6001        json_download_url: artifacts
6002            .json_path
6003            .as_ref()
6004            .map(|_| format!("/runs/json/{run_id}?download=1")),
6005        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6006        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6007        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6008        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6009        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6010        prev_fa_str,
6011        prev_fs_str,
6012        prev_pl_str,
6013        prev_cl_str,
6014        prev_cml_str,
6015        prev_bl_str,
6016        delta_fa_str,
6017        delta_fa_class,
6018        delta_fs_str,
6019        delta_fs_class,
6020        delta_pl_str,
6021        delta_pl_class,
6022        delta_cl_str,
6023        delta_cl_class,
6024        delta_cml_str,
6025        delta_cml_class,
6026        delta_bl_str,
6027        delta_bl_class,
6028        delta_lines_added,
6029        delta_lines_removed,
6030        delta_lines_net_str,
6031        delta_lines_net_class,
6032        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6033        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6034        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6035        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6036        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6037        git_branch,
6038        git_branch_url,
6039        git_commit,
6040        git_commit_long,
6041        git_author,
6042        git_commit_url,
6043        scan_performed_by,
6044        scan_time_display,
6045        os_display,
6046        test_count,
6047        test_assertion_count: run.summary_totals.test_assertion_count,
6048        current_scan_number: prev_scan_count + 1,
6049        prev_scan_count,
6050        submodule_rows: run
6051            .submodule_summaries
6052            .iter()
6053            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6054            .collect(),
6055        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6056        scan_config_url: format!("/runs/scan-config/{run_id}"),
6057        lang_chart_json: build_lang_chart_json(run),
6058        scatter_chart_json: build_scatter_chart_json(run),
6059        semantic_chart_json: build_semantic_chart_json(run),
6060        submodule_chart_json: build_submodule_chart_json(run),
6061        has_submodule_data: !run.submodule_summaries.is_empty(),
6062        has_semantic_data: run
6063            .totals_by_language
6064            .iter()
6065            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6066        csp_nonce: csp_nonce.to_owned(),
6067        confluence_configured,
6068        server_mode,
6069        report_header_footer: run
6070            .effective_configuration
6071            .reporting
6072            .report_header_footer
6073            .clone(),
6074        is_offline: false,
6075        cyclomatic_complexity,
6076        lsloc,
6077        uloc,
6078        dryness_pct_str,
6079        duplicate_group_count,
6080        has_cocomo,
6081        cocomo_effort_str,
6082        cocomo_duration_str,
6083        cocomo_staff_str,
6084        cocomo_ksloc_str,
6085        cocomo_mode_label,
6086        cocomo_mode_tooltip,
6087        complexity_alert,
6088        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6089        cov_line_pct: cov_pct_str(
6090            run.summary_totals.coverage_lines_hit,
6091            run.summary_totals.coverage_lines_found,
6092        ),
6093        cov_fn_pct: cov_pct_str(
6094            run.summary_totals.coverage_functions_hit,
6095            run.summary_totals.coverage_functions_found,
6096        ),
6097        cov_branch_pct: cov_pct_str(
6098            run.summary_totals.coverage_branches_hit,
6099            run.summary_totals.coverage_branches_found,
6100        ),
6101        cov_lines_summary: cov_lines_summary_str(
6102            run.summary_totals.coverage_lines_hit,
6103            run.summary_totals.coverage_lines_found,
6104        ),
6105    };
6106
6107    Html(
6108        template
6109            .render()
6110            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6111    )
6112    .into_response()
6113}
6114
6115fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6116    let slug: String = report_title
6117        .chars()
6118        .map(|c| {
6119            if c.is_alphanumeric() || c == '-' {
6120                c.to_ascii_lowercase()
6121            } else {
6122                '_'
6123            }
6124        })
6125        .collect::<String>()
6126        .split('_')
6127        .filter(|s| !s.is_empty())
6128        .collect::<Vec<_>>()
6129        .join("_");
6130
6131    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6132
6133    if slug.is_empty() {
6134        format!("report_{short_id}.pdf")
6135    } else {
6136        format!("{slug}_{short_id}.pdf")
6137    }
6138}
6139
6140#[derive(Serialize)]
6141struct PdfStatusResponse {
6142    ready: bool,
6143}
6144
6145/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6146/// Clients poll this to update the button state without page reloads.
6147async fn pdf_status_handler(
6148    State(state): State<AppState>,
6149    AxumPath(run_id): AxumPath<String>,
6150) -> Response {
6151    let pdf_path = {
6152        let registry = state.artifacts.lock().await;
6153        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6154    };
6155    let pdf_path = if pdf_path.is_some() {
6156        pdf_path
6157    } else {
6158        let reg = state.registry.lock().await;
6159        reg.find_by_run_id(&run_id)
6160            .map(recover_artifacts_from_registry)
6161            .and_then(|a| a.pdf_path)
6162    };
6163    let ready = pdf_path.is_some_and(|p| p.exists());
6164    Json(PdfStatusResponse { ready }).into_response()
6165}
6166
6167/// GET /`api/runs/:run_id/bundle`
6168///
6169/// Streams a gzip-compressed tar archive containing every artifact in the run's
6170/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6171/// is built in memory so it never touches a temp file.
6172async fn download_bundle_handler(
6173    State(state): State<AppState>,
6174    AxumPath(run_id): AxumPath<String>,
6175) -> Response {
6176    // Resolve output directory from in-memory cache or persisted registry.
6177    let output_dir = {
6178        let cache = state.artifacts.lock().await;
6179        cache.get(&run_id).map(|a| a.output_dir.clone())
6180    };
6181    let output_dir = if let Some(d) = output_dir {
6182        d
6183    } else {
6184        let reg = state.registry.lock().await;
6185        match reg.find_by_run_id(&run_id) {
6186            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6187            None => {
6188                return (
6189                    StatusCode::NOT_FOUND,
6190                    Json(serde_json::json!({"error": "Run not found"})),
6191                )
6192                    .into_response();
6193            }
6194        }
6195    };
6196
6197    if !output_dir.exists() {
6198        return (
6199            StatusCode::NOT_FOUND,
6200            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6201        )
6202            .into_response();
6203    }
6204
6205    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6206    let run_id_clone = run_id.clone();
6207    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6208        use flate2::{write::GzEncoder, Compression};
6209        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6210        {
6211            let mut tar = tar::Builder::new(&mut enc);
6212            tar.follow_symlinks(false);
6213            // Append every regular file in the output directory, skipping
6214            // sub-directories (the output dir is always flat).
6215            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6216                for entry in entries.filter_map(Result::ok) {
6217                    let p = entry.path();
6218                    if p.is_file() {
6219                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6220                        let archive_path = format!("{run_id_clone}/{name}");
6221                        tar.append_path_with_name(&p, &archive_path)?;
6222                    }
6223                }
6224            }
6225            tar.finish()?;
6226        }
6227        Ok(enc.finish()?)
6228    })
6229    .await;
6230
6231    match archive_result {
6232        Ok(Ok(bytes)) => {
6233            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6234            axum::response::Response::builder()
6235                .status(StatusCode::OK)
6236                .header("Content-Type", "application/gzip")
6237                .header(
6238                    "Content-Disposition",
6239                    format!("attachment; filename=\"{filename}\""),
6240                )
6241                .header("Content-Length", bytes.len().to_string())
6242                .body(axum::body::Body::from(bytes))
6243                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6244        }
6245        Ok(Err(e)) => (
6246            StatusCode::INTERNAL_SERVER_ERROR,
6247            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6248        )
6249            .into_response(),
6250        Err(e) => (
6251            StatusCode::INTERNAL_SERVER_ERROR,
6252            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6253        )
6254            .into_response(),
6255    }
6256}
6257
6258/// DELETE /`api/runs/:run_id`
6259///
6260/// Removes all on-disk artifacts for the run and purges the run from the
6261/// in-memory cache and the persisted registry. Returns 204 on success.
6262async fn delete_run_handler(
6263    State(state): State<AppState>,
6264    AxumPath(run_id): AxumPath<String>,
6265) -> Response {
6266    // Resolve output directory.
6267    let output_dir = {
6268        let mut cache = state.artifacts.lock().await;
6269        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6270        cache.remove(&run_id);
6271        dir
6272    };
6273    let output_dir = if let Some(d) = output_dir {
6274        d
6275    } else {
6276        let reg = state.registry.lock().await;
6277        reg.find_by_run_id(&run_id)
6278            .map(|e| recover_artifacts_from_registry(e).output_dir)
6279            .unwrap_or_default()
6280    };
6281
6282    // Remove from persisted registry.
6283    {
6284        let mut reg = state.registry.lock().await;
6285        reg.entries.retain(|e| e.run_id != run_id);
6286        let _ = reg.save(&state.registry_path);
6287    }
6288
6289    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6290    // a prior delete may have already removed the directory.
6291    if output_dir.exists() {
6292        match tokio::fs::remove_dir_all(&output_dir).await {
6293            Ok(()) => {}
6294            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6295            Err(e) => {
6296                return (
6297                    StatusCode::INTERNAL_SERVER_ERROR,
6298                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6299                )
6300                    .into_response();
6301            }
6302        }
6303    }
6304
6305    StatusCode::NO_CONTENT.into_response()
6306}
6307
6308/// POST /api/runs/cleanup
6309///
6310/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6311/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6312async fn cleanup_runs_handler(
6313    State(state): State<AppState>,
6314    Json(body): Json<serde_json::Value>,
6315) -> Response {
6316    let days = body
6317        .get("older_than_days")
6318        .and_then(serde_json::Value::as_u64)
6319        .unwrap_or(30)
6320        .max(1);
6321
6322    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6323
6324    // Collect expired entries from the registry.
6325    let expired: Vec<(String, PathBuf)> = {
6326        let reg = state.registry.lock().await;
6327        reg.entries
6328            .iter()
6329            .filter(|e| e.timestamp_utc < cutoff)
6330            .map(|e| {
6331                let arts = recover_artifacts_from_registry(e);
6332                (e.run_id.clone(), arts.output_dir)
6333            })
6334            .collect()
6335    };
6336
6337    let mut deleted = 0usize;
6338    for (run_id, output_dir) in &expired {
6339        // Remove from in-memory cache.
6340        state.artifacts.lock().await.remove(run_id);
6341        // Delete on-disk artifacts (non-fatal if already gone).
6342        if output_dir.exists() {
6343            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6344                eprintln!(
6345                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6346                    output_dir.display()
6347                );
6348                continue;
6349            }
6350        }
6351        deleted += 1;
6352    }
6353
6354    // Purge expired run IDs from the registry in one pass.
6355    let expired_ids: std::collections::HashSet<&str> =
6356        expired.iter().map(|(id, _)| id.as_str()).collect();
6357    {
6358        let mut reg = state.registry.lock().await;
6359        reg.entries
6360            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6361        let _ = reg.save(&state.registry_path);
6362    }
6363
6364    Json(serde_json::json!({ "deleted": deleted })).into_response()
6365}
6366
6367/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6368/// abort it when the policy is updated or disabled.
6369fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6370    tokio::spawn(async move {
6371        loop {
6372            let interval_secs = {
6373                let store = state.cleanup_policy.lock().await;
6374                match &store.policy {
6375                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6376                    _ => break,
6377                }
6378            };
6379            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6380            let n = run_auto_cleanup(&state).await;
6381            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6382        }
6383    })
6384}
6385
6386fn collect_runs_to_delete(
6387    reg: &ScanRegistry,
6388    max_age_days: Option<u32>,
6389    max_run_count: Option<u32>,
6390) -> std::collections::HashSet<String> {
6391    let mut to_delete = std::collections::HashSet::new();
6392    if let Some(days) = max_age_days {
6393        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6394        for e in &reg.entries {
6395            if e.timestamp_utc < cutoff {
6396                to_delete.insert(e.run_id.clone());
6397            }
6398        }
6399    }
6400    if let Some(max_count) = max_run_count {
6401        // entries are sorted newest-first; skip the ones we keep
6402        for e in reg.entries.iter().skip(max_count as usize) {
6403            to_delete.insert(e.run_id.clone());
6404        }
6405    }
6406    to_delete
6407}
6408
6409async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6410    let output_dir = {
6411        let mut cache = state.artifacts.lock().await;
6412        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6413        cache.remove(run_id);
6414        d
6415    };
6416    let output_dir = if let Some(d) = output_dir {
6417        d
6418    } else {
6419        let reg = state.registry.lock().await;
6420        reg.find_by_run_id(run_id)
6421            .map(|e| recover_artifacts_from_registry(e).output_dir)
6422            .unwrap_or_default()
6423    };
6424    if output_dir.exists() {
6425        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6426    }
6427}
6428
6429/// Core cleanup logic shared by the background task and the "Run Now" handler.
6430/// Applies both the age limit and the count limit, then updates `last_run_at`.
6431/// Returns the number of runs deleted.
6432async fn run_auto_cleanup(state: &AppState) -> u32 {
6433    let (max_age_days, max_run_count) = {
6434        let store = state.cleanup_policy.lock().await;
6435        match &store.policy {
6436            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6437            _ => return 0,
6438        }
6439    };
6440
6441    let to_delete = {
6442        let reg = state.registry.lock().await;
6443        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6444    };
6445
6446    for run_id in &to_delete {
6447        delete_run_artifacts(state, run_id).await;
6448    }
6449
6450    // Purge from registry.
6451    if !to_delete.is_empty() {
6452        let mut reg = state.registry.lock().await;
6453        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6454        let _ = reg.save(&state.registry_path);
6455    }
6456
6457    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6458    {
6459        let mut store = state.cleanup_policy.lock().await;
6460        store.last_run_at = Some(chrono::Utc::now());
6461        store.last_run_deleted = Some(deleted);
6462        let _ = store.save(&state.cleanup_policy_path);
6463    }
6464    deleted
6465}
6466
6467// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6468
6469/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6470async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6471    let store = state.cleanup_policy.lock().await;
6472    Json(serde_json::json!({
6473        "policy": store.policy,
6474        "last_run_at": store.last_run_at,
6475        "last_run_deleted": store.last_run_deleted,
6476    }))
6477    .into_response()
6478}
6479
6480/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6481async fn api_save_cleanup_policy(
6482    State(state): State<AppState>,
6483    Json(body): Json<CleanupPolicy>,
6484) -> Response {
6485    // Abort any running task so the new interval takes effect immediately.
6486    {
6487        let mut handle = state.cleanup_task_handle.lock().await;
6488        if let Some(h) = handle.take() {
6489            h.abort();
6490        }
6491    }
6492    {
6493        let mut store = state.cleanup_policy.lock().await;
6494        store.policy = Some(body.clone());
6495        if let Err(e) = store.save(&state.cleanup_policy_path) {
6496            return (
6497                StatusCode::INTERNAL_SERVER_ERROR,
6498                Json(serde_json::json!({"error": e.to_string()})),
6499            )
6500                .into_response();
6501        }
6502    }
6503    if body.enabled {
6504        let handle = spawn_cleanup_policy_task(state.clone());
6505        *state.cleanup_task_handle.lock().await = Some(handle);
6506    }
6507    StatusCode::NO_CONTENT.into_response()
6508}
6509
6510/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6511async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6512    let deleted = run_auto_cleanup(&state).await;
6513    Json(serde_json::json!({ "deleted": deleted })).into_response()
6514}
6515
6516/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6517async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6518    {
6519        let mut handle = state.cleanup_task_handle.lock().await;
6520        if let Some(h) = handle.take() {
6521            h.abort();
6522        }
6523    }
6524    {
6525        let mut store = state.cleanup_policy.lock().await;
6526        store.policy = None;
6527        let _ = store.save(&state.cleanup_policy_path);
6528    }
6529    StatusCode::NO_CONTENT.into_response()
6530}
6531
6532/// Serve the HTML artifact for a run — view or download.
6533/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6534/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6535/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6536/// Only called for browser views; downloads keep the self-contained inline version.
6537fn swap_inline_chart_js_for_static(html: String) -> String {
6538    let Some(head_end) = html.find("</head>") else {
6539        return html;
6540    };
6541    let Some(script_start) = html[..head_end].rfind("<script") else {
6542        return html;
6543    };
6544    let Some(close_offset) = html[script_start..].find("</script>") else {
6545        return html;
6546    };
6547    let block_end = script_start + close_offset + "</script>".len();
6548    format!(
6549        "{}<script src=\"/static/chart-report.js\"></script>{}",
6550        &html[..script_start],
6551        &html[block_end..]
6552    )
6553}
6554
6555/// current-request Content-Security-Policy nonce check.
6556fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6557    // Find the first nonce value that was baked in at render time.
6558    let Some(start) = html.find("nonce=\"") else {
6559        // Reports generated before nonce support was added have bare <style> and <script>
6560        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6561        // the inline blocks — without it the browser blocks all CSS and JS.
6562        return html
6563            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6564            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6565    };
6566    let value_start = start + 7; // len(r#"nonce=""#) == 7
6567    let Some(end_offset) = html[value_start..].find('"') else {
6568        return html.to_owned();
6569    };
6570    let old_nonce = &html[value_start..value_start + end_offset];
6571    html.replace(
6572        &format!("nonce=\"{old_nonce}\""),
6573        &format!("nonce=\"{new_nonce}\""),
6574    )
6575}
6576
6577fn serve_html_artifact(
6578    path: &Path,
6579    wants_download: bool,
6580    csp_nonce: &str,
6581    run_id: &str,
6582    server_mode: bool,
6583) -> Response {
6584    match fs::read_to_string(path) {
6585        Ok(raw) => {
6586            // Patch the saved nonce so inline styles/scripts pass CSP.
6587            let content = patch_html_nonce(&raw, csp_nonce);
6588            if wants_download {
6589                // Keep the self-contained inline version for downloads (opened as file://).
6590                (
6591                    [
6592                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6593                        (
6594                            header::CONTENT_DISPOSITION,
6595                            "attachment; filename=report.html",
6596                        ),
6597                    ],
6598                    content,
6599                )
6600                    .into_response()
6601            } else {
6602                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
6603                // browser caches it after the first view; the HTML response also shrinks.
6604                Html(swap_inline_chart_js_for_static(content)).into_response()
6605            }
6606        }
6607        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6608            let filename = path.file_name().map_or_else(
6609                || "report.html".to_string(),
6610                |n| n.to_string_lossy().into_owned(),
6611            );
6612            let html = LocateFileTemplate {
6613                run_id: run_id.to_owned(),
6614                artifact_type: "html".to_string(),
6615                expected_filename: filename,
6616                server_mode,
6617                csp_nonce: csp_nonce.to_owned(),
6618                version: env!("CARGO_PKG_VERSION"),
6619            }
6620            .render()
6621            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6622            (StatusCode::NOT_FOUND, Html(html)).into_response()
6623        }
6624        Err(err) => {
6625            let filename = path.file_name().map_or_else(
6626                || "report.html".to_string(),
6627                |n| n.to_string_lossy().into_owned(),
6628            );
6629            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6630            let html = ErrorTemplate {
6631                message: msg,
6632                last_report_url: Some("/view-reports".to_string()),
6633                last_report_label: Some("View Reports".to_string()),
6634                run_id: None,
6635                error_code: Some(404),
6636                csp_nonce: csp_nonce.to_owned(),
6637                version: env!("CARGO_PKG_VERSION"),
6638            }
6639            .render()
6640            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6641            (StatusCode::NOT_FOUND, Html(html)).into_response()
6642        }
6643    }
6644}
6645
6646/// Serve the PDF artifact for a run — inline or download.
6647fn serve_pdf_artifact(
6648    path: &Path,
6649    report_title: &str,
6650    run_id: &str,
6651    wants_download: bool,
6652    csp_nonce: &str,
6653) -> Response {
6654    match fs::read(path) {
6655        Ok(bytes) => {
6656            let filename = build_pdf_filename(report_title, run_id);
6657            let disposition = if wants_download {
6658                format!("attachment; filename=\"{filename}\"")
6659            } else {
6660                format!("inline; filename=\"{filename}\"")
6661            };
6662            (
6663                [
6664                    (header::CONTENT_TYPE, "application/pdf".to_string()),
6665                    (header::CONTENT_DISPOSITION, disposition),
6666                ],
6667                bytes,
6668            )
6669                .into_response()
6670        }
6671        Err(err) => {
6672            let filename = path.file_name().map_or_else(
6673                || "report.pdf".to_string(),
6674                |n| n.to_string_lossy().into_owned(),
6675            );
6676            let msg = format!(
6677                "PDF report '{filename}' could not be read.\n\n\
6678                 Error: {err}\n\n\
6679                 If you moved or renamed the output folder, the stored path is now stale. \
6680                 Use 'Open PDF folder' from the results page to browse the output directory."
6681            );
6682            let html = ErrorTemplate {
6683                message: msg,
6684                last_report_url: Some("/view-reports".to_string()),
6685                last_report_label: Some("View Reports".to_string()),
6686                run_id: Some(run_id.to_owned()),
6687                error_code: Some(404),
6688                csp_nonce: csp_nonce.to_owned(),
6689                version: env!("CARGO_PKG_VERSION"),
6690            }
6691            .render()
6692            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6693            (StatusCode::NOT_FOUND, Html(html)).into_response()
6694        }
6695    }
6696}
6697
6698/// Serve the JSON artifact for a run — view or download.
6699fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6700    match fs::read(path) {
6701        Ok(bytes) => {
6702            if wants_download {
6703                (
6704                    [
6705                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6706                        (
6707                            header::CONTENT_DISPOSITION,
6708                            "attachment; filename=result.json",
6709                        ),
6710                    ],
6711                    bytes,
6712                )
6713                    .into_response()
6714            } else {
6715                (
6716                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6717                    bytes,
6718                )
6719                    .into_response()
6720            }
6721        }
6722        Err(err) => {
6723            let filename = path.file_name().map_or_else(
6724                || "result.json".to_string(),
6725                |n| n.to_string_lossy().into_owned(),
6726            );
6727            let msg = format!(
6728                "JSON result '{filename}' could not be read.\n\n\
6729                 Error: {err}\n\n\
6730                 If you moved or renamed the output folder, the stored path is now stale. \
6731                 Use 'Open JSON folder' from the results page to browse the output directory."
6732            );
6733            let html = ErrorTemplate {
6734                message: msg,
6735                last_report_url: Some("/view-reports".to_string()),
6736                last_report_label: Some("View Reports".to_string()),
6737                run_id: None,
6738                error_code: Some(404),
6739                csp_nonce: csp_nonce.to_owned(),
6740                version: env!("CARGO_PKG_VERSION"),
6741            }
6742            .render()
6743            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6744            (StatusCode::NOT_FOUND, Html(html)).into_response()
6745        }
6746    }
6747}
6748
6749/// Recover a `RunArtifacts` from the persisted registry for a run ID.
6750fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6751    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
6752    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
6753    let output_dir = entry
6754        .html_path
6755        .as_ref()
6756        .or(entry.json_path.as_ref())
6757        .or(entry.pdf_path.as_ref())
6758        .or(entry.csv_path.as_ref())
6759        .or(entry.xlsx_path.as_ref())
6760        .and_then(|p| {
6761            let parent = p.parent()?;
6762            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6763            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
6764            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6765                parent.parent().map(PathBuf::from)
6766            } else {
6767                Some(parent.to_path_buf())
6768            }
6769        })
6770        .unwrap_or_default();
6771    // Recover pdf_path: use the persisted one, or look for report.pdf
6772    // adjacent to html/json if only the old entries lack it.
6773    let pdf_path = entry.pdf_path.clone().or_else(|| {
6774        let candidate = output_dir.join("report.pdf");
6775        candidate.exists().then_some(candidate)
6776    });
6777    // csv_path / xlsx_path: persisted paths take precedence; fall back to
6778    // scanning the run directory for files matching the expected patterns so
6779    // that runs created before this feature still surface their artifacts.
6780    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6781        // Check excel/ subfolder (new layout) then root (old layout).
6782        for dir in &[output_dir.join("excel"), output_dir.clone()] {
6783            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6784                entries
6785                    .filter_map(std::result::Result::ok)
6786                    .find(|e| {
6787                        let n = e.file_name();
6788                        let n = n.to_string_lossy();
6789                        n.starts_with("report_") && n.ends_with(ext)
6790                    })
6791                    .map(|e| e.path())
6792            }) {
6793                return Some(p);
6794            }
6795        }
6796        None
6797    };
6798
6799    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6800    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6801    RunArtifacts {
6802        output_dir: output_dir.clone(),
6803        html_path: entry.html_path.clone(),
6804        pdf_path,
6805        json_path: entry.json_path.clone(),
6806        csv_path,
6807        xlsx_path,
6808        scan_config_path: find_scan_config_in_dir(&output_dir),
6809        report_title: entry.project_label.clone(),
6810        result_context: RunResultContext::default(),
6811    }
6812}
6813
6814#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6815async fn resolve_artifact_set(
6816    state: &AppState,
6817    run_id: &str,
6818    csp_nonce: &str,
6819) -> Result<RunArtifacts, Response> {
6820    let cached = state.artifacts.lock().await.get(run_id).cloned();
6821    if let Some(a) = cached {
6822        return Ok(a);
6823    }
6824    let reg = state.registry.lock().await;
6825    if let Some(entry) = reg.find_by_run_id(run_id) {
6826        return Ok(recover_artifacts_from_registry(entry));
6827    }
6828    drop(reg);
6829    let short_id = &run_id[..run_id.len().min(8)];
6830    let hint = if matches!(
6831        run_id,
6832        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6833    ) {
6834        format!(
6835            " The URL format appears to be reversed \u{2014} \
6836             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6837             Use the View Reports page to navigate to your scan."
6838        )
6839    } else {
6840        " The report may have been deleted or the report directory moved. \
6841         Use View Reports to browse your scan history."
6842            .to_string()
6843    };
6844    let error_html = ErrorTemplate {
6845        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6846        last_report_url: Some("/view-reports".to_string()),
6847        last_report_label: Some("View Reports".to_string()),
6848        run_id: None,
6849        error_code: Some(404),
6850        csp_nonce: csp_nonce.to_owned(),
6851        version: env!("CARGO_PKG_VERSION"),
6852    }
6853    .render()
6854    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6855    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6856}
6857
6858/// Return the path to a run's PDF, queuing background generation when it is missing.
6859///
6860/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6861/// Returns `Err(response)` when there is no JSON source to regenerate from.
6862async fn resolve_or_queue_pdf(
6863    state: &AppState,
6864    pdf_path: Option<PathBuf>,
6865    json_path: Option<PathBuf>,
6866    output_dir: PathBuf,
6867    run_id: &str,
6868    report_title: &str,
6869    csp_nonce: &str,
6870) -> Result<PathBuf, Response> {
6871    if let Some(p) = pdf_path {
6872        return Ok(p);
6873    }
6874    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6875        let msg = "PDF report was not generated for this run. \
6876                   Re-run the analysis with PDF output enabled."
6877            .to_string();
6878        let html = ErrorTemplate {
6879            message: msg,
6880            last_report_url: Some(format!("/runs/html/{run_id}")),
6881            last_report_label: Some("View HTML Report".to_string()),
6882            run_id: Some(run_id.to_string()),
6883            error_code: Some(404),
6884            csp_nonce: csp_nonce.to_string(),
6885            version: env!("CARGO_PKG_VERSION"),
6886        }
6887        .render()
6888        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6889        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6890    };
6891    let pdf_filename = build_pdf_filename(report_title, run_id);
6892    let pdf_dest = output_dir.join(&pdf_filename);
6893    if !pdf_dest.exists() {
6894        // Record the pending path so concurrent requests show the spinner.
6895        {
6896            let mut map = state.artifacts.lock().await;
6897            if let Some(entry) = map.get_mut(run_id) {
6898                entry.pdf_path = Some(pdf_dest.clone());
6899            }
6900        }
6901        {
6902            let mut reg = state.registry.lock().await;
6903            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6904                e.pdf_path = Some(pdf_dest.clone());
6905            }
6906            let _ = reg.save(&state.registry_path);
6907        }
6908        spawn_native_pdf_background(
6909            json_src,
6910            pdf_dest.clone(),
6911            run_id.to_string(),
6912            state.artifacts.clone(),
6913        );
6914    }
6915    Ok(pdf_dest)
6916}
6917
6918/// Self-refreshing "please wait" page shown while the background PDF task is still running.
6919fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6920    let html = format!(
6921                    "<!doctype html><html lang=\"en\"><head>\
6922                     <meta charset=utf-8>\
6923                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6924                     <meta http-equiv=\"refresh\" content=\"5\">\
6925                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
6926                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6927                     <style nonce=\"{csp_nonce}\">\
6928                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6929                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6930                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6931                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6932                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6933                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6934                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6935                     background:var(--bg);color:var(--text);}}\
6936                     .top-nav{{position:sticky;top:0;z-index:30;\
6937                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6938                     border-bottom:1px solid rgba(255,255,255,0.12);\
6939                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6940                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6941                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
6942                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6943                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6944                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6945                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6946                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6947                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6948                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6949                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6950                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6951                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6952                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6953                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6954                     justify-content:center;min-height:38px;border-radius:999px;\
6955                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6956                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6957                     .theme-toggle .icon-sun{{display:none;}}\
6958                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6959                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6960                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6961                     display:flex;align-items:center;justify-content:center;\
6962                     min-height:calc(100vh - 56px);}}\
6963                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6964                     .panel{{background:var(--surface);border:1px solid var(--line);\
6965                     border-radius:var(--radius);box-shadow:var(--shadow);\
6966                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6967                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
6968                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
6969                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6970                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6971                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6972                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6973                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6974                     min-height:42px;padding:0 20px;border-radius:14px;\
6975                     border:1px solid var(--line-strong);text-decoration:none;\
6976                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6977                     .back-link:hover{{background:var(--line);}}\
6978                     </style></head>\
6979                     <body>\
6980                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6981                       <a class=\"brand\" href=\"/\">\
6982                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6983                         <div class=\"brand-copy\">\
6984                           <div class=\"brand-title\">OxideSLOC</div>\
6985                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6986                         </div>\
6987                       </a>\
6988                       <div class=\"nav-right\">\
6989                         <a class=\"nav-pill\" href=\"/\">Home</a>\
6990                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6991                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6992                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6993                           <svg class=\"icon-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z\"></path></svg>\
6994                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6995                           <path d=\"M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1\"></path></svg>\
6996                         </button>\
6997                       </div>\
6998                     </div></div>\
6999                     <div class=\"page\"><div class=\"panel\">\
7000                       <div class=\"spin-ring\"></div>\
7001                       <h1>Generating PDF\u{2026}</h1>\
7002                       <p>The PDF is being generated from the scan results.<br>\
7003                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7004                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7005                     </div></div>\
7006                     <script nonce=\"{csp_nonce}\">\
7007                     (function(){{\
7008                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7009                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7010                       var t=document.getElementById(\"theme-toggle\");\
7011                       if(t)t.addEventListener(\"click\",function(){{\
7012                         var d=b.classList.toggle(\"dark-theme\");\
7013                         localStorage.setItem(k,d?\"dark\":\"light\");\
7014                       }});\
7015                     }})();\
7016                     </script>\
7017                     </body></html>"
7018    );
7019    Html(html).into_response()
7020}
7021
7022/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7023fn render_error_artifact_html(
7024    message: String,
7025    last_report_url: Option<String>,
7026    last_report_label: Option<String>,
7027    run_id: Option<String>,
7028    error_code: Option<u16>,
7029    csp_nonce: &str,
7030) -> String {
7031    ErrorTemplate {
7032        message,
7033        last_report_url,
7034        last_report_label,
7035        run_id,
7036        error_code,
7037        csp_nonce: csp_nonce.to_owned(),
7038        version: env!("CARGO_PKG_VERSION"),
7039    }
7040    .render()
7041    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7042}
7043
7044/// Read a file and serve it as an attachment download.
7045fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7046    fs::read(path).map_or_else(
7047        |_| StatusCode::NOT_FOUND.into_response(),
7048        |bytes| {
7049            let filename = path.file_name().map_or_else(
7050                || fallback_filename.to_string(),
7051                |n| n.to_string_lossy().into_owned(),
7052            );
7053            (
7054                [
7055                    (header::CONTENT_TYPE, content_type.to_string()),
7056                    (
7057                        header::CONTENT_DISPOSITION,
7058                        format!("attachment; filename=\"{filename}\""),
7059                    ),
7060                ],
7061                bytes,
7062            )
7063                .into_response()
7064        },
7065    )
7066}
7067
7068fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7069    let Some(path) = csv_path else {
7070        let html = render_error_artifact_html(
7071            "CSV report was not generated for this run, or was not recorded in \
7072             the scan registry."
7073                .to_string(),
7074            Some(format!("/runs/html/{run_id}")),
7075            Some("View HTML Report".to_string()),
7076            Some(run_id.to_string()),
7077            Some(404),
7078            csp_nonce,
7079        );
7080        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7081    };
7082    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7083}
7084
7085fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7086    let Some(path) = xlsx_path else {
7087        let html = render_error_artifact_html(
7088            "Excel report was not generated for this run, or was not recorded in \
7089             the scan registry."
7090                .to_string(),
7091            Some(format!("/runs/html/{run_id}")),
7092            Some("View HTML Report".to_string()),
7093            Some(run_id.to_string()),
7094            Some(404),
7095            csp_nonce,
7096        );
7097        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7098    };
7099    serve_binary_download(
7100        &path,
7101        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7102        "report.xlsx",
7103    )
7104}
7105
7106fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7107    let path = artifact_set
7108        .scan_config_path
7109        .as_deref()
7110        .map(std::path::Path::to_path_buf)
7111        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7112        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7113    fs::read(&path).map_or_else(
7114        |_| StatusCode::NOT_FOUND.into_response(),
7115        |bytes| {
7116            (
7117                [
7118                    (
7119                        header::CONTENT_TYPE,
7120                        "application/json; charset=utf-8".to_string(),
7121                    ),
7122                    (
7123                        header::CONTENT_DISPOSITION,
7124                        "attachment; filename=\"scan-config.json\"".to_string(),
7125                    ),
7126                ],
7127                bytes,
7128            )
7129                .into_response()
7130        },
7131    )
7132}
7133
7134/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7135/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7136/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7137/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7138async fn serve_submodule_pdf_arm(
7139    artifact: &str,
7140    artifact_set: RunArtifacts,
7141    wants_download: bool,
7142    run_id: &str,
7143    csp_nonce: &str,
7144) -> Response {
7145    // "sub_benchmark_pdf" → base = "sub_benchmark"
7146    let base = artifact.trim_end_matches("_pdf");
7147    let sub_dir = artifact_set.output_dir.join("submodules");
7148    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7149
7150    if !pdf_path.exists() {
7151        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7152        let derived_safe = base.trim_start_matches("sub_");
7153        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7154            let parent_run = read_json(jp).ok()?;
7155            let sub = parent_run
7156                .submodule_summaries
7157                .iter()
7158                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7159                .clone();
7160            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7161            Some((parent_run, sub, parent_path))
7162        });
7163
7164        if let Some((parent_run, sub, parent_path)) = rebuilt {
7165            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7166            let pp = pdf_path.clone();
7167            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7168        }
7169    }
7170
7171    if !pdf_path.exists() {
7172        let html = render_error_artifact_html(
7173            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7174             enabled."
7175                .to_string(),
7176            Some("/view-reports".to_string()),
7177            Some("View Reports".to_string()),
7178            Some(run_id.to_string()),
7179            Some(404),
7180            csp_nonce,
7181        );
7182        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7183    }
7184
7185    serve_pdf_artifact(
7186        &pdf_path,
7187        &artifact_set.report_title,
7188        run_id,
7189        wants_download,
7190        csp_nonce,
7191    )
7192}
7193
7194fn serve_submodule_arm(
7195    artifact: &str,
7196    artifact_set: &RunArtifacts,
7197    wants_download: bool,
7198    csp_nonce: &str,
7199    run_id: &str,
7200    server_mode: bool,
7201) -> Response {
7202    if artifact.len() > 128
7203        || !artifact
7204            .chars()
7205            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7206    {
7207        return StatusCode::BAD_REQUEST.into_response();
7208    }
7209    let filename = format!("{artifact}.html");
7210    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7211    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7212    let path = if new_layout.exists() {
7213        new_layout
7214    } else {
7215        artifact_set.output_dir.join(&filename)
7216    };
7217    if !path.exists() {
7218        let html = render_error_artifact_html(
7219            format!(
7220                "Sub-report '{artifact}' was not found in the run directory.\n\
7221                 Re-run the analysis with 'Detect and separate git submodules' \
7222                 and HTML output enabled."
7223            ),
7224            Some("/view-reports".to_string()),
7225            Some("View Reports".to_string()),
7226            Some(run_id.to_string()),
7227            Some(404),
7228            csp_nonce,
7229        );
7230        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7231    }
7232    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7233}
7234
7235async fn serve_pdf_arm(
7236    state: &AppState,
7237    artifact_set: RunArtifacts,
7238    wants_download: bool,
7239    run_id: &str,
7240    csp_nonce: &str,
7241) -> Response {
7242    let report_title = artifact_set.report_title.clone();
7243    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7244    let stale_html_name = artifact_set
7245        .html_path
7246        .as_deref()
7247        .and_then(|p| p.file_name())
7248        .map(|n| n.to_string_lossy().into_owned());
7249    let path = match resolve_or_queue_pdf(
7250        state,
7251        artifact_set.pdf_path,
7252        artifact_set.json_path.clone(),
7253        artifact_set.output_dir.clone(),
7254        run_id,
7255        &report_title,
7256        csp_nonce,
7257    )
7258    .await
7259    {
7260        Ok(p) => p,
7261        Err(r) => return r,
7262    };
7263    if !path.exists() {
7264        // Distinguish a stale registry path (folder moved) from an in-progress
7265        // background generation. Only show the locate page when the PDF was
7266        // already recorded in the registry but the file is now missing.
7267        if had_pdf_in_registry {
7268            if let Some(expected_filename) = stale_html_name {
7269                let html = LocateFileTemplate {
7270                    run_id: run_id.to_string(),
7271                    artifact_type: "pdf".to_string(),
7272                    expected_filename,
7273                    server_mode: state.server_mode,
7274                    csp_nonce: csp_nonce.to_string(),
7275                    version: env!("CARGO_PKG_VERSION"),
7276                }
7277                .render()
7278                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7279                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7280            }
7281        }
7282        return pdf_generating_response(run_id, csp_nonce);
7283    }
7284    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7285}
7286
7287async fn artifact_handler(
7288    State(state): State<AppState>,
7289    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7290    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7291    Query(query): Query<ArtifactQuery>,
7292) -> Response {
7293    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7294        Ok(a) => a,
7295        Err(r) => return r,
7296    };
7297
7298    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7299
7300    match artifact.as_str() {
7301        "html" => {
7302            let Some(path) = artifact_set.html_path else {
7303                return StatusCode::NOT_FOUND.into_response();
7304            };
7305            serve_html_artifact(
7306                &path,
7307                wants_download,
7308                &csp_nonce,
7309                &run_id,
7310                state.server_mode,
7311            )
7312        }
7313        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7314        "json" => {
7315            let Some(path) = artifact_set.json_path else {
7316                let html = render_error_artifact_html(
7317                    "JSON result was not generated for this run, or was not recorded in \
7318                     the scan registry. Re-run the analysis with JSON output enabled."
7319                        .to_string(),
7320                    Some("/view-reports".to_string()),
7321                    Some("View Reports".to_string()),
7322                    Some(run_id.clone()),
7323                    Some(404),
7324                    &csp_nonce,
7325                );
7326                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7327            };
7328            serve_json_artifact(&path, wants_download, &csp_nonce)
7329        }
7330        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7331        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7332        "scan-config" => serve_scan_config_arm(&artifact_set),
7333        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7334            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7335                .await
7336        }
7337        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7338            &artifact,
7339            &artifact_set,
7340            wants_download,
7341            &csp_nonce,
7342            &run_id,
7343            state.server_mode,
7344        ),
7345        _ => StatusCode::NOT_FOUND.into_response(),
7346    }
7347}
7348
7349// ── History ───────────────────────────────────────────────────────────────────
7350
7351struct SubmoduleLinkRow {
7352    name: String,
7353    url: String,
7354}
7355
7356struct HistoryEntryRow {
7357    run_id: String,
7358    run_id_short: String,
7359    timestamp: String,
7360    timestamp_utc_ms: i64,
7361    project_label: String,
7362    project_path: String,
7363    files_analyzed: u64,
7364    files_skipped: u64,
7365    code_lines: u64,
7366    comment_lines: u64,
7367    blank_lines: u64,
7368    total_physical_lines: u64,
7369    functions: u64,
7370    classes: u64,
7371    variables: u64,
7372    imports: u64,
7373    test_count: u64,
7374    git_branch: String,
7375    git_commit: String,
7376    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7377    git_commit_long: String,
7378    has_html: bool,
7379    has_json: bool,
7380    has_pdf: bool,
7381    submodule_links: Vec<SubmoduleLinkRow>,
7382    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7383    submodule_names_csv: String,
7384}
7385
7386/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7387fn nth_weekday_of_month(
7388    year: i32,
7389    month: u32,
7390    weekday: chrono::Weekday,
7391    n: u32,
7392) -> chrono::NaiveDate {
7393    use chrono::Datelike;
7394    let mut count = 0u32;
7395    let mut day = 1u32;
7396    loop {
7397        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7398        if d.weekday() == weekday {
7399            count += 1;
7400            if count == n {
7401                return d;
7402            }
7403        }
7404        day += 1;
7405    }
7406}
7407
7408/// Returns true if `dt` falls within US Pacific Daylight Time.
7409/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7410/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7411fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7412    use chrono::{Datelike, TimeZone};
7413    let year = dt.year();
7414    let dst_start = chrono::Utc.from_utc_datetime(
7415        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7416            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7417    );
7418    let dst_end = chrono::Utc.from_utc_datetime(
7419        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7420            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7421    );
7422    dt >= dst_start && dt < dst_end
7423}
7424
7425fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7426    if is_pacific_dst(dt) {
7427        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7428            .format("%Y-%m-%d %H:%M PDT")
7429            .to_string()
7430    } else {
7431        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7432            .format("%Y-%m-%d %H:%M PST")
7433            .to_string()
7434    }
7435}
7436
7437/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7438fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7439    let (offset, tz) = if is_pacific_dst(dt) {
7440        (
7441            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7442            "PDT",
7443        )
7444    } else {
7445        (
7446            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7447            "PST",
7448        )
7449    };
7450    format!(
7451        "{} {tz}",
7452        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7453    )
7454}
7455
7456fn fmt_git_date(iso: &str) -> Option<String> {
7457    chrono::DateTime::parse_from_rfc3339(iso)
7458        .ok()
7459        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7460}
7461
7462/// Recover the full-length commit SHA for a registry entry whose stored record
7463/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7464///
7465/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7466/// is serialized after the per-file records, near the end of the file. We read a
7467/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7468/// known short SHA — this disambiguates the super-repo commit from any submodule
7469/// commits that also appear. Returns `None` if the file is unreadable or no match.
7470fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7471    use std::io::{Read, Seek, SeekFrom};
7472    if short.is_empty() {
7473        return None;
7474    }
7475    let len = std::fs::metadata(path).ok()?.len();
7476    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7477    let start = len.saturating_sub(TAIL);
7478    let mut file = std::fs::File::open(path).ok()?;
7479    file.seek(SeekFrom::Start(start)).ok()?;
7480    let mut buf = Vec::new();
7481    file.read_to_end(&mut buf).ok()?;
7482    let text = String::from_utf8_lossy(&buf);
7483    let short_lower = short.to_ascii_lowercase();
7484    let key = "\"git_commit_long\"";
7485    let mut found: Option<String> = None;
7486    let mut cursor = 0usize;
7487    while let Some(idx) = text[cursor..].find(key) {
7488        let after_key = cursor + idx + key.len();
7489        cursor = after_key;
7490        let rest = &text[after_key..];
7491        let Some(colon) = rest.find(':') else { break };
7492        let value_region = rest[colon + 1..].trim_start();
7493        // Skip `null` (or any non-string) values without consuming the next field.
7494        if let Some(open) = value_region.strip_prefix('"') {
7495            if let Some(close) = open.find('"') {
7496                let val = &open[..close];
7497                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7498                    found = Some(val.to_string());
7499                }
7500            }
7501        }
7502    }
7503    found
7504}
7505
7506fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7507    reg.entries
7508        .iter()
7509        .map(|e| {
7510            let submodule_links = {
7511                let mut links: Vec<SubmoduleLinkRow> = vec![];
7512                let sub_dir = e
7513                    .html_path
7514                    .as_ref()
7515                    .and_then(|p| p.parent())
7516                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7517                if let Some(dir) = sub_dir {
7518                    if let Ok(rd) = std::fs::read_dir(dir) {
7519                        for entry_res in rd.flatten() {
7520                            let fname = entry_res.file_name();
7521                            let fname_str = fname.to_string_lossy();
7522                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7523                                let stem = &fname_str[..fname_str.len() - 5];
7524                                let display = stem[4..].replace('-', " ");
7525                                links.push(SubmoduleLinkRow {
7526                                    name: display,
7527                                    url: format!("/runs/{stem}/{}", e.run_id),
7528                                });
7529                            }
7530                        }
7531                    }
7532                }
7533                links.sort_by(|a, b| a.name.cmp(&b.name));
7534                links
7535            };
7536            let submodule_names_csv = submodule_links
7537                .iter()
7538                .map(|l| l.name.as_str())
7539                .collect::<Vec<_>>()
7540                .join(",");
7541            HistoryEntryRow {
7542                run_id: e.run_id.clone(),
7543                run_id_short: e
7544                    .run_id
7545                    .split('-')
7546                    .next_back()
7547                    .unwrap_or(&e.run_id)
7548                    .chars()
7549                    .take(7)
7550                    .collect(),
7551                timestamp: fmt_la_time(e.timestamp_utc),
7552                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7553                project_label: e.project_label.clone(),
7554                project_path: e
7555                    .input_roots
7556                    .first()
7557                    .map(|s| sanitize_path_str(s))
7558                    .unwrap_or_default(),
7559                files_analyzed: e.summary.files_analyzed,
7560                files_skipped: e.summary.files_skipped,
7561                code_lines: e.summary.code_lines,
7562                comment_lines: e.summary.comment_lines,
7563                blank_lines: e.summary.blank_lines,
7564                total_physical_lines: e.summary.total_physical_lines,
7565                functions: e.summary.functions,
7566                classes: e.summary.classes,
7567                variables: e.summary.variables,
7568                imports: e.summary.imports,
7569                test_count: e.summary.test_count,
7570                git_branch: e.git_branch.clone().unwrap_or_default(),
7571                git_commit: e.git_commit.clone().unwrap_or_default(),
7572                git_commit_long: {
7573                    let short = e.git_commit.clone().unwrap_or_default();
7574                    e.git_commit_long
7575                        .clone()
7576                        .filter(|s| !s.is_empty())
7577                        .or_else(|| {
7578                            e.json_path
7579                                .as_ref()
7580                                .and_then(|p| extract_long_commit_from_json(p, &short))
7581                        })
7582                        .unwrap_or(short)
7583                },
7584                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7585                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7586                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7587                submodule_links,
7588                submodule_names_csv,
7589            }
7590        })
7591        .collect()
7592}
7593
7594#[derive(Deserialize, Default)]
7595struct HistoryQuery {
7596    linked: Option<String>,
7597    error: Option<String>,
7598}
7599
7600async fn history_handler(
7601    State(state): State<AppState>,
7602    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7603    Query(query): Query<HistoryQuery>,
7604) -> impl IntoResponse {
7605    // Auto-scan all watched directories before rendering so the list stays fresh.
7606    auto_scan_watched_dirs(&state).await;
7607    let watched_dirs: Vec<String> = {
7608        let wd = state.watched_dirs.lock().await;
7609        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7610    };
7611    let mut entries = {
7612        let reg = state.registry.lock().await;
7613        make_history_rows(&reg)
7614    };
7615    entries.retain(|e| e.has_html);
7616    let total_scans = entries.len();
7617    let linked_count = query
7618        .linked
7619        .as_deref()
7620        .and_then(|s| s.parse::<usize>().ok())
7621        .unwrap_or(0);
7622    let browse_error = query.error.filter(|s| !s.is_empty());
7623    let template = HistoryTemplate {
7624        version: env!("CARGO_PKG_VERSION"),
7625        entries,
7626        total_scans,
7627        linked_count,
7628        browse_error,
7629        watched_dirs,
7630        csp_nonce,
7631        server_mode: state.server_mode,
7632    };
7633    Html(
7634        template
7635            .render()
7636            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7637    )
7638    .into_response()
7639}
7640
7641async fn compare_select_handler(
7642    State(state): State<AppState>,
7643    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7644) -> impl IntoResponse {
7645    auto_scan_watched_dirs(&state).await;
7646    let watched_dirs: Vec<String> = {
7647        let wd = state.watched_dirs.lock().await;
7648        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7649    };
7650    let mut entries = {
7651        let reg = state.registry.lock().await;
7652        make_history_rows(&reg)
7653    };
7654    entries.retain(|e| e.has_json);
7655    let total_scans = entries.len();
7656    let template = CompareSelectTemplate {
7657        version: env!("CARGO_PKG_VERSION"),
7658        entries,
7659        total_scans,
7660        watched_dirs,
7661        csp_nonce,
7662        server_mode: state.server_mode,
7663    };
7664    Html(
7665        template
7666            .render()
7667            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7668    )
7669    .into_response()
7670}
7671
7672// ── Compare ───────────────────────────────────────────────────────────────────
7673
7674#[derive(Deserialize, Default)]
7675struct CompareQuery {
7676    a: Option<String>,
7677    b: Option<String>,
7678    /// Optional submodule name to scope the comparison to one submodule.
7679    sub: Option<String>,
7680    /// "super" to exclude all submodule files and show only the super-repo.
7681    scope: Option<String>,
7682}
7683
7684struct CompareFileDeltaRow {
7685    relative_path: String,
7686    language: String,
7687    status: String,
7688    baseline_code: i64,
7689    current_code: i64,
7690    baseline_code_display: String,
7691    current_code_display: String,
7692    code_delta_str: String,
7693    code_delta_class: String,
7694    comment_delta_str: String,
7695    comment_delta_class: String,
7696    total_delta_str: String,
7697    total_delta_class: String,
7698}
7699
7700/// Recompute `summary_totals` from the current `per_file_records` slice.
7701/// Used when `per_file_records` has been narrowed to a submodule subset.
7702fn recompute_summary_from_records(run: &mut AnalysisRun) {
7703    let mut totals = SummaryTotals::default();
7704    for r in &run.per_file_records {
7705        if r.language.is_some() {
7706            totals.files_analyzed += 1;
7707        }
7708        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7709        totals.code_lines += r.effective_counts.code_lines;
7710        totals.comment_lines += r.effective_counts.comment_lines;
7711        totals.blank_lines += r.effective_counts.blank_lines;
7712        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7713        totals.functions += r.raw_line_categories.functions;
7714        totals.classes += r.raw_line_categories.classes;
7715        totals.variables += r.raw_line_categories.variables;
7716        totals.imports += r.raw_line_categories.imports;
7717        totals.test_count += r.raw_line_categories.test_count;
7718        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7719        totals.test_suite_count += r.raw_line_categories.test_suite_count;
7720        if let Some(cov) = &r.coverage {
7721            totals.coverage_lines_found += u64::from(cov.lines_found);
7722            totals.coverage_lines_hit += u64::from(cov.lines_hit);
7723            totals.coverage_functions_found += u64::from(cov.functions_found);
7724            totals.coverage_functions_hit += u64::from(cov.functions_hit);
7725            totals.coverage_branches_found += u64::from(cov.branches_found);
7726            totals.coverage_branches_hit += u64::from(cov.branches_hit);
7727        }
7728    }
7729    totals.files_considered = totals.files_analyzed;
7730    run.summary_totals = totals;
7731}
7732
7733fn fmt_delta(n: i64) -> String {
7734    if n > 0 {
7735        format!("+{n}")
7736    } else {
7737        format!("{n}")
7738    }
7739}
7740
7741fn delta_class(n: i64) -> &'static str {
7742    use std::cmp::Ordering;
7743    match n.cmp(&0) {
7744        Ordering::Greater => "pos",
7745        Ordering::Less => "neg",
7746        Ordering::Equal => "zero",
7747    }
7748}
7749
7750// ratio/percentage display, precision loss acceptable
7751#[allow(clippy::cast_precision_loss)]
7752fn fmt_pct(delta: i64, baseline: u64) -> String {
7753    if baseline == 0 {
7754        return "—".to_string();
7755    }
7756    #[allow(clippy::cast_precision_loss)]
7757    let pct = (delta as f64 / baseline as f64) * 100.0;
7758    if pct > 0.049 {
7759        format!("+{pct:.1}%")
7760    } else if pct < -0.049 {
7761        format!("{pct:.1}%")
7762    } else {
7763        "±0%".to_string()
7764    }
7765}
7766
7767/// Returns (`display_string`, `css_class`) for a numeric change column cell.
7768fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7769    prev.map_or_else(
7770        || ("—".to_string(), "na"),
7771        |p| {
7772            #[allow(clippy::cast_possible_wrap)]
7773            let d = curr as i64 - p as i64;
7774            (fmt_delta(d), delta_class(d))
7775        },
7776    )
7777}
7778
7779#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
7780fn load_scan_for_compare(
7781    json_path: &std::path::Path,
7782    scan_label: &str,
7783    run_id: &str,
7784    server_mode: bool,
7785    compare_url: &str,
7786    csp_nonce: &str,
7787) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7788    match read_json(json_path) {
7789        Ok(r) => Ok(r),
7790        Err(e) => {
7791            if server_mode {
7792                let html = ErrorTemplate {
7793                    message: format!(
7794                        "Could not load {scan_label} scan data. The scan output folder may have \
7795                         been moved, renamed, or deleted. Re-running the analysis will create \
7796                         fresh comparison data."
7797                    ),
7798                    last_report_url: Some("/compare-scans".to_string()),
7799                    last_report_label: Some("Compare Scans".to_string()),
7800                    run_id: Some(run_id.to_owned()),
7801                    error_code: Some(404),
7802                    csp_nonce: csp_nonce.to_owned(),
7803                    version: env!("CARGO_PKG_VERSION"),
7804                }
7805                .render()
7806                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7807                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7808            }
7809            let msg = format!(
7810                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7811                json_path.display()
7812            );
7813            let folder_hint = output_folder_hint(json_path);
7814            Err(missing_scan_relocate_response(
7815                &msg,
7816                run_id,
7817                &folder_hint,
7818                compare_url,
7819                false,
7820                csp_nonce,
7821            ))
7822        }
7823    }
7824}
7825
7826struct ChurnStats {
7827    new_scope: bool,
7828    scope_flag: bool,
7829    churn_rate_str: String,
7830    churn_rate_class: String,
7831}
7832
7833fn compute_churn_stats(
7834    baseline_code: u64,
7835    current_code: u64,
7836    lines_added: i64,
7837    lines_removed: i64,
7838) -> ChurnStats {
7839    let new_scope = baseline_code == 0 && current_code > 0;
7840    #[allow(clippy::cast_precision_loss)]
7841    let churn_pct = if baseline_code > 0 {
7842        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7843    } else {
7844        0.0
7845    };
7846    #[allow(clippy::cast_precision_loss)]
7847    let scope_flag =
7848        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7849    let churn_rate_str = if new_scope {
7850        "New".to_string()
7851    } else if baseline_code > 0 {
7852        format!("{churn_pct:.1}%")
7853    } else {
7854        "—".to_string()
7855    };
7856    let churn_rate_class = if new_scope || churn_pct > 20.0 {
7857        "high".to_string()
7858    } else if churn_pct > 5.0 {
7859        "med".to_string()
7860    } else {
7861        "low".to_string()
7862    };
7863    ChurnStats {
7864        new_scope,
7865        scope_flag,
7866        churn_rate_str,
7867        churn_rate_class,
7868    }
7869}
7870
7871/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7872/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7873/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7874fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7875    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7876    if !has_data {
7877        return String::new();
7878    }
7879    let base_str = s
7880        .baseline_coverage_line_pct
7881        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7882    let curr_str = s
7883        .current_coverage_line_pct
7884        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7885    let (delta_str, cls) = match s.coverage_line_pct_delta {
7886        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7887        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7888        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7889        None => ("\u{2014}".into(), "zero"),
7890    };
7891    format!(
7892        r#"<div class="delta-card">
7893          <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
7894          <div class="delta-card-label">Line coverage</div>
7895          <div class="delta-card-from">Before: {base_str}</div>
7896          <div class="delta-card-to">{curr_str}</div>
7897          <span class="delta-card-change {cls}">{delta_str}</span>
7898        </div>"#
7899    )
7900}
7901
7902/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
7903#[allow(clippy::ref_option)]
7904fn narrow_run_pair_by_scope(
7905    mut baseline: AnalysisRun,
7906    mut current: AnalysisRun,
7907    active_sub: &Option<String>,
7908    super_scope: bool,
7909) -> (AnalysisRun, AnalysisRun) {
7910    if let Some(ref sub_name) = active_sub {
7911        baseline
7912            .per_file_records
7913            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7914        current
7915            .per_file_records
7916            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7917        recompute_summary_from_records(&mut baseline);
7918        recompute_summary_from_records(&mut current);
7919    } else if super_scope {
7920        baseline.per_file_records.retain(|f| f.submodule.is_none());
7921        current.per_file_records.retain(|f| f.submodule.is_none());
7922        recompute_summary_from_records(&mut baseline);
7923        recompute_summary_from_records(&mut current);
7924    }
7925    (baseline, current)
7926}
7927
7928/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
7929#[allow(clippy::ref_option)]
7930fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7931    if let Some(ref sub_name) = active_sub {
7932        for run in runs.iter_mut() {
7933            run.per_file_records
7934                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7935            recompute_summary_from_records(run);
7936        }
7937    } else if super_scope {
7938        for run in runs.iter_mut() {
7939            run.per_file_records.retain(|f| f.submodule.is_none());
7940            recompute_summary_from_records(run);
7941        }
7942    }
7943}
7944
7945#[allow(clippy::too_many_lines)]
7946async fn compare_handler(
7947    State(state): State<AppState>,
7948    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7949    Query(query): Query<CompareQuery>,
7950) -> impl IntoResponse {
7951    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
7952    // redirect to the history page where the user can select two runs.
7953    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7954        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7955        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7956    };
7957
7958    let (maybe_a, maybe_b) = {
7959        let reg = state.registry.lock().await;
7960        (
7961            reg.find_by_run_id(&run_id_a).cloned(),
7962            reg.find_by_run_id(&run_id_b).cloned(),
7963        )
7964    };
7965
7966    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7967        let html = ErrorTemplate {
7968            message: "One or both run IDs were not found in scan history. \
7969                      The runs may have been deleted or the registry may have been reset."
7970                .to_string(),
7971            last_report_url: Some("/compare-scans".to_string()),
7972            last_report_label: Some("Compare Scans".to_string()),
7973            run_id: None,
7974            error_code: None,
7975            csp_nonce: csp_nonce.clone(),
7976            version: env!("CARGO_PKG_VERSION"),
7977        }
7978        .render()
7979        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7980        return Html(html).into_response();
7981    };
7982
7983    // Ensure older scan is always the baseline.
7984    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7985        (entry_a, entry_b)
7986    } else {
7987        (entry_b, entry_a)
7988    };
7989
7990    // If query params were in the wrong order, redirect to canonical URL so the
7991    // browser always shows the same URL for the same two scans regardless of how
7992    // the user arrived here (Full diff button vs. Compare Scans selection).
7993    if baseline_entry.run_id != run_id_a {
7994        let canonical = format!(
7995            "/compare?a={}&b={}",
7996            baseline_entry.run_id, current_entry.run_id
7997        );
7998        return axum::response::Redirect::to(&canonical).into_response();
7999    }
8000
8001    let (Some(base_json), Some(curr_json)) = (
8002        baseline_entry.json_path.as_ref(),
8003        current_entry.json_path.as_ref(),
8004    ) else {
8005        let html = ErrorTemplate {
8006            message: "Full comparison requires JSON scan data, which was not saved for one or \
8007                      both of these runs. JSON is now always saved for new scans — re-run the \
8008                      affected projects to enable comparisons."
8009                .to_string(),
8010            last_report_url: Some("/compare-scans".to_string()),
8011            last_report_label: Some("Compare Scans".to_string()),
8012            run_id: None,
8013            error_code: None,
8014            csp_nonce: csp_nonce.clone(),
8015            version: env!("CARGO_PKG_VERSION"),
8016        }
8017        .render()
8018        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8019        return Html(html).into_response();
8020    };
8021
8022    let compare_url = format!(
8023        "/compare?a={}&b={}",
8024        baseline_entry.run_id, current_entry.run_id
8025    );
8026
8027    let baseline_run = match load_scan_for_compare(
8028        base_json,
8029        "baseline",
8030        &baseline_entry.run_id,
8031        state.server_mode,
8032        &compare_url,
8033        &csp_nonce,
8034    ) {
8035        Ok(r) => r,
8036        Err(resp) => return resp,
8037    };
8038    let current_run = match load_scan_for_compare(
8039        curr_json,
8040        "current",
8041        &current_entry.run_id,
8042        state.server_mode,
8043        &compare_url,
8044        &csp_nonce,
8045    ) {
8046        Ok(r) => r,
8047        Err(resp) => return resp,
8048    };
8049
8050    let active_submodule = query.sub.clone();
8051    let super_scope_active = query.scope.as_deref() == Some("super");
8052
8053    let submodule_options = baseline_run
8054        .submodule_summaries
8055        .iter()
8056        .chain(current_run.submodule_summaries.iter())
8057        .map(|s| s.name.clone())
8058        .collect::<std::collections::BTreeSet<_>>()
8059        .into_iter()
8060        .collect::<Vec<_>>();
8061    let has_any_submodule_data = !submodule_options.is_empty();
8062
8063    // Narrow per_file_records when a scope is active, then recompute totals.
8064    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8065        baseline_run,
8066        current_run,
8067        &active_submodule,
8068        super_scope_active,
8069    );
8070
8071    let comparison = compute_delta(&effective_baseline, &effective_current);
8072
8073    let file_rows: Vec<CompareFileDeltaRow> = comparison
8074        .file_deltas
8075        .iter()
8076        .map(|d| CompareFileDeltaRow {
8077            relative_path: d.relative_path.clone(),
8078            language: d.language.clone().unwrap_or_else(|| "—".into()),
8079            status: match d.status {
8080                FileChangeStatus::Added => "added".into(),
8081                FileChangeStatus::Removed => "removed".into(),
8082                FileChangeStatus::Modified => "modified".into(),
8083                FileChangeStatus::Unchanged => "unchanged".into(),
8084            },
8085            baseline_code: d.baseline_code,
8086            current_code: d.current_code,
8087            baseline_code_display: if d.status == FileChangeStatus::Added {
8088                "—".into()
8089            } else {
8090                d.baseline_code.to_string()
8091            },
8092            current_code_display: if d.status == FileChangeStatus::Removed {
8093                "—".into()
8094            } else {
8095                d.current_code.to_string()
8096            },
8097            code_delta_str: fmt_delta(d.code_delta),
8098            code_delta_class: delta_class(d.code_delta).into(),
8099            comment_delta_str: fmt_delta(d.comment_delta),
8100            comment_delta_class: delta_class(d.comment_delta).into(),
8101            total_delta_str: fmt_delta(d.total_delta),
8102            total_delta_class: delta_class(d.total_delta).into(),
8103        })
8104        .collect();
8105
8106    let project_path = baseline_entry
8107        .input_roots
8108        .first()
8109        .map(|s| sanitize_path_str(s))
8110        .unwrap_or_default();
8111    let lines_added = sum_added_code_lines(&comparison);
8112    let lines_removed = sum_removed_code_lines(&comparison);
8113    let churn = compute_churn_stats(
8114        comparison.summary.baseline_code,
8115        comparison.summary.current_code,
8116        lines_added,
8117        lines_removed,
8118    );
8119    let s = &comparison.summary;
8120    let template = CompareTemplate {
8121        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8122        version: env!("CARGO_PKG_VERSION"),
8123        project_label: baseline_entry.project_label.clone(),
8124        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8125        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8126        baseline_run_id: baseline_entry.run_id.clone(),
8127        current_run_id: current_entry.run_id.clone(),
8128        baseline_run_id_short: baseline_entry
8129            .run_id
8130            .split('-')
8131            .next_back()
8132            .unwrap_or(&baseline_entry.run_id)
8133            .chars()
8134            .take(7)
8135            .collect(),
8136        current_run_id_short: current_entry
8137            .run_id
8138            .split('-')
8139            .next_back()
8140            .unwrap_or(&current_entry.run_id)
8141            .chars()
8142            .take(7)
8143            .collect(),
8144        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8145        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8146        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8147        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8148        project_path: project_path.clone(),
8149        baseline_code: s.baseline_code,
8150        current_code: s.current_code,
8151        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8152        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8153        baseline_files: s.baseline_files,
8154        current_files: s.current_files,
8155        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8156        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8157        baseline_comments: s.baseline_comments,
8158        current_comments: s.current_comments,
8159        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8160        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8161        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8162        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8163        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8164        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8165        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8166        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8167        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8168        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8169        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8170        code_lines_added: lines_added,
8171        code_lines_removed: lines_removed,
8172        new_scope: churn.new_scope,
8173        churn_rate_str: churn.churn_rate_str,
8174        churn_rate_class: churn.churn_rate_class,
8175        scope_flag: churn.scope_flag,
8176        files_added: comparison.files_added,
8177        files_removed: comparison.files_removed,
8178        files_modified: comparison.files_modified,
8179        files_unchanged: comparison.files_unchanged,
8180        file_rows,
8181        baseline_git_author: baseline_entry.git_author.clone(),
8182        current_git_author: current_entry.git_author.clone(),
8183        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8184        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8185        baseline_git_tags: baseline_entry.git_tags.clone(),
8186        current_git_tags: current_entry.git_tags.clone(),
8187        baseline_git_commit_date: baseline_entry
8188            .git_commit_date
8189            .as_deref()
8190            .and_then(fmt_git_date),
8191        current_git_commit_date: current_entry
8192            .git_commit_date
8193            .as_deref()
8194            .and_then(fmt_git_date),
8195        project_name: project_path
8196            .rsplit(['/', '\\'])
8197            .find(|s| !s.is_empty())
8198            .unwrap_or(&project_path)
8199            .to_string(),
8200        submodule_options,
8201        has_any_submodule_data,
8202        active_submodule,
8203        super_scope_active,
8204        toast_assets: sloc_toast_assets(&csp_nonce),
8205        csp_nonce,
8206        coverage_delta_card: build_coverage_delta_card(s),
8207        baseline_test_count: effective_baseline.summary_totals.test_count,
8208        current_test_count: effective_current.summary_totals.test_count,
8209        baseline_coverage_pct: s.baseline_coverage_line_pct,
8210        current_coverage_pct: s.current_coverage_line_pct,
8211    };
8212
8213    Html(
8214        template
8215            .render()
8216            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8217    )
8218    .into_response()
8219}
8220
8221// ── Badge endpoint ────────────────────────────────────────────────────────────
8222// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8223// pages, Jira descriptions, etc.
8224//
8225// GET /badge/<metric>?label=<override>&color=<hex>
8226// Metrics: code-lines  files  comment-lines  blank-lines
8227
8228fn format_number(n: u64) -> String {
8229    let s = n.to_string();
8230    let mut out = String::with_capacity(s.len() + s.len() / 3);
8231    let len = s.len();
8232    for (i, c) in s.chars().enumerate() {
8233        if i > 0 && (len - i).is_multiple_of(3) {
8234            out.push(',');
8235        }
8236        out.push(c);
8237    }
8238    out
8239}
8240
8241const fn badge_char_width(c: char) -> f64 {
8242    match c {
8243        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8244        'm' | 'w' => 9.0,
8245        ' ' => 4.0,
8246        _ => 6.5,
8247    }
8248}
8249
8250#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8251fn badge_text_px(text: &str) -> u32 {
8252    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8253}
8254
8255fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8256    let lw = badge_text_px(label) + 20;
8257    let rw = badge_text_px(value) + 20;
8258    let total = lw + rw;
8259    let lx = lw / 2;
8260    let rx = lw + rw / 2;
8261    let le = escape_html(label);
8262    let ve = escape_html(value);
8263    let ce = escape_html(color);
8264    format!(
8265        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8266  <rect width="{total}" height="20" fill="#555"/>
8267  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8268  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8269    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8270    <text x="{lx}" y="13">{le}</text>
8271    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8272    <text x="{rx}" y="13">{ve}</text>
8273  </g>
8274</svg>"##
8275    )
8276}
8277
8278#[derive(Deserialize)]
8279struct BadgeQuery {
8280    label: Option<String>,
8281    color: Option<String>,
8282}
8283
8284async fn badge_handler(
8285    State(state): State<AppState>,
8286    AxumPath(metric): AxumPath<String>,
8287    Query(query): Query<BadgeQuery>,
8288) -> Response {
8289    let entry = {
8290        let reg = state.registry.lock().await;
8291        reg.entries.first().cloned()
8292    };
8293
8294    let Some(entry) = entry else {
8295        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8296        return (
8297            [
8298                (header::CONTENT_TYPE, "image/svg+xml"),
8299                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8300            ],
8301            svg,
8302        )
8303            .into_response();
8304    };
8305
8306    let (default_label, value, default_color) = match metric.as_str() {
8307        "code-lines" => (
8308            "code lines",
8309            format_number(entry.summary.code_lines),
8310            "#4a78ee",
8311        ),
8312        "files" => (
8313            "files analyzed",
8314            format_number(entry.summary.files_analyzed),
8315            "#4a9862",
8316        ),
8317        "comment-lines" => (
8318            "comment lines",
8319            format_number(entry.summary.comment_lines),
8320            "#b35428",
8321        ),
8322        "blank-lines" => (
8323            "blank lines",
8324            format_number(entry.summary.blank_lines),
8325            "#7a5db0",
8326        ),
8327        _ => return StatusCode::NOT_FOUND.into_response(),
8328    };
8329
8330    let label = query.label.as_deref().unwrap_or(default_label);
8331    let color = query.color.as_deref().unwrap_or(default_color);
8332    let svg = render_badge_svg(label, &value, color);
8333
8334    (
8335        [
8336            (header::CONTENT_TYPE, "image/svg+xml"),
8337            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8338        ],
8339        svg,
8340    )
8341        .into_response()
8342}
8343
8344// ── Metrics API ───────────────────────────────────────────────────────────────
8345// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8346// Confluence automation, Jira webhooks, etc.
8347//
8348// GET /api/metrics/latest
8349// GET /api/metrics/<run_id>
8350
8351#[derive(Serialize)]
8352struct ApiCoverageBlock {
8353    lines_found: u64,
8354    lines_hit: u64,
8355    line_pct: f64,
8356    functions_found: u64,
8357    functions_hit: u64,
8358    function_pct: f64,
8359    branches_found: u64,
8360    branches_hit: u64,
8361    branch_pct: f64,
8362}
8363
8364#[derive(Serialize)]
8365struct ApiMetricsResponse {
8366    run_id: String,
8367    timestamp: String,
8368    project: String,
8369    summary: ApiSummaryPayload,
8370    languages: Vec<ApiLanguageRow>,
8371    #[serde(skip_serializing_if = "Option::is_none")]
8372    coverage: Option<ApiCoverageBlock>,
8373}
8374
8375#[derive(Serialize)]
8376struct ApiSummaryPayload {
8377    files_analyzed: u64,
8378    files_skipped: u64,
8379    code_lines: u64,
8380    comment_lines: u64,
8381    blank_lines: u64,
8382    total_physical_lines: u64,
8383    functions: u64,
8384    classes: u64,
8385    variables: u64,
8386    imports: u64,
8387}
8388
8389#[derive(Serialize)]
8390struct ApiLanguageRow {
8391    name: String,
8392    files: u64,
8393    code_lines: u64,
8394    comment_lines: u64,
8395    blank_lines: u64,
8396    functions: u64,
8397    classes: u64,
8398    variables: u64,
8399    imports: u64,
8400}
8401
8402async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8403    let entry = {
8404        let reg = state.registry.lock().await;
8405        reg.entries.first().cloned()
8406    };
8407    entry.map_or_else(
8408        || error::not_found("no scans recorded yet"),
8409        |e| build_metrics_response(&e),
8410    )
8411}
8412
8413async fn api_metrics_run_handler(
8414    State(state): State<AppState>,
8415    AxumPath(run_id): AxumPath<String>,
8416) -> Response {
8417    let entry = {
8418        let reg = state.registry.lock().await;
8419        reg.find_by_run_id(&run_id).cloned()
8420    };
8421    entry.map_or_else(
8422        || error::not_found("run not found"),
8423        |e| build_metrics_response(&e),
8424    )
8425}
8426
8427fn build_metrics_response(entry: &RegistryEntry) -> Response {
8428    let languages: Vec<ApiLanguageRow> = entry
8429        .json_path
8430        .as_ref()
8431        .and_then(|p| read_json(p).ok())
8432        .map(|run| {
8433            run.totals_by_language
8434                .iter()
8435                .map(|l| ApiLanguageRow {
8436                    name: l.language.display_name().to_string(),
8437                    files: l.files,
8438                    code_lines: l.code_lines,
8439                    comment_lines: l.comment_lines,
8440                    blank_lines: l.blank_lines,
8441                    functions: l.functions,
8442                    classes: l.classes,
8443                    variables: l.variables,
8444                    imports: l.imports,
8445                })
8446                .collect()
8447        })
8448        .unwrap_or_default();
8449
8450    let s = &entry.summary;
8451    let coverage = if s.coverage_lines_found > 0 {
8452        let pct = |hit: u64, found: u64| -> f64 {
8453            if found == 0 {
8454                0.0
8455            } else {
8456                #[allow(clippy::cast_precision_loss)]
8457                let v = (hit as f64 / found as f64) * 100.0;
8458                (v * 10.0).round() / 10.0
8459            }
8460        };
8461        Some(ApiCoverageBlock {
8462            lines_found: s.coverage_lines_found,
8463            lines_hit: s.coverage_lines_hit,
8464            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8465            functions_found: s.coverage_functions_found,
8466            functions_hit: s.coverage_functions_hit,
8467            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8468            branches_found: s.coverage_branches_found,
8469            branches_hit: s.coverage_branches_hit,
8470            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8471        })
8472    } else {
8473        None
8474    };
8475    Json(ApiMetricsResponse {
8476        run_id: entry.run_id.clone(),
8477        timestamp: entry.timestamp_utc.to_rfc3339(),
8478        project: entry.project_label.clone(),
8479        summary: ApiSummaryPayload {
8480            files_analyzed: s.files_analyzed,
8481            files_skipped: s.files_skipped,
8482            code_lines: s.code_lines,
8483            comment_lines: s.comment_lines,
8484            blank_lines: s.blank_lines,
8485            total_physical_lines: s.total_physical_lines,
8486            functions: s.functions,
8487            classes: s.classes,
8488            variables: s.variables,
8489            imports: s.imports,
8490        },
8491        languages,
8492        coverage,
8493    })
8494    .into_response()
8495}
8496
8497// ── Project history API ───────────────────────────────────────────────────────
8498// Protected. Called by the wizard JS when the project path changes, so the UI
8499// can show a "scanned N times before" badge without a full page reload.
8500//
8501// GET /api/project-history?path=<project_root>
8502
8503#[derive(Deserialize)]
8504struct ProjectHistoryQuery {
8505    path: Option<String>,
8506}
8507
8508#[derive(Serialize)]
8509struct ProjectHistoryResponse {
8510    scan_count: usize,
8511    last_scan_id: Option<String>,
8512    last_scan_timestamp: Option<String>,
8513    last_scan_code_lines: Option<u64>,
8514    last_git_branch: Option<String>,
8515    last_git_commit: Option<String>,
8516}
8517
8518/// Return true if `entry` matches either an exact root path or an upload-staging
8519/// path with the same project name (needed because each upload gets a fresh UUID dir).
8520fn entry_matches_project(
8521    entry: &RegistryEntry,
8522    root_str: &str,
8523    upload_root: &str,
8524    upload_name_suffix: Option<&str>,
8525) -> bool {
8526    if entry.input_roots.iter().any(|r| r == root_str) {
8527        return true;
8528    }
8529    if let Some(suffix) = upload_name_suffix {
8530        return entry
8531            .input_roots
8532            .iter()
8533            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8534    }
8535    false
8536}
8537
8538async fn project_history_handler(
8539    State(state): State<AppState>,
8540    Query(query): Query<ProjectHistoryQuery>,
8541) -> Response {
8542    let path = query.path.unwrap_or_default();
8543    let resolved = resolve_input_path(&path);
8544    let root_str = resolved.to_string_lossy().replace('\\', "/");
8545
8546    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8547    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8548    // previous scans of the same project. Fall back to matching by project name within the
8549    // uploads staging directory so Scan History populates correctly across uploads.
8550    let upload_root = std::env::temp_dir()
8551        .join("oxide-sloc-uploads")
8552        .to_string_lossy()
8553        .replace('\\', "/");
8554    let upload_name_suffix: Option<String> =
8555        if state.server_mode && root_str.starts_with(&upload_root) {
8556            resolved
8557                .file_name()
8558                .and_then(|n| n.to_str())
8559                .map(|name| format!("/{name}"))
8560        } else {
8561            None
8562        };
8563    let suffix_ref = upload_name_suffix.as_deref();
8564
8565    let entries: Vec<_> = {
8566        let reg = state.registry.lock().await;
8567        reg.entries
8568            .iter()
8569            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8570            .cloned()
8571            .collect()
8572    };
8573    let scan_count = entries.len();
8574    let last = entries.first();
8575    let last_scan_id = last.map(|e| e.run_id.clone());
8576    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8577    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8578    let last_git_branch = last.and_then(|e| e.git_branch.clone());
8579    let last_git_commit = last.and_then(|e| e.git_commit.clone());
8580
8581    Json(ProjectHistoryResponse {
8582        scan_count,
8583        last_scan_id,
8584        last_scan_timestamp,
8585        last_scan_code_lines,
8586        last_git_branch,
8587        last_git_commit,
8588    })
8589    .into_response()
8590}
8591
8592// ── Metrics history API ───────────────────────────────────────────────────────
8593// Protected. Returns a JSON array of lightweight scan snapshots for plotting
8594// trend charts.
8595//
8596// GET /api/metrics/history?root=<path>&limit=<n>
8597
8598#[derive(Deserialize)]
8599struct MetricsHistoryQuery {
8600    root: Option<String>,
8601    limit: Option<usize>,
8602    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
8603    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
8604    submodule: Option<String>,
8605}
8606
8607#[derive(Serialize)]
8608struct MetricsSubmoduleLink {
8609    name: String,
8610    url: String,
8611}
8612
8613#[derive(Serialize)]
8614struct MetricsHistoryEntry {
8615    run_id: String,
8616    run_id_short: String,
8617    timestamp: String,
8618    commit: Option<String>,
8619    branch: Option<String>,
8620    tags: Vec<String>,
8621    nearest_tag: Option<String>,
8622    code_lines: u64,
8623    comment_lines: u64,
8624    blank_lines: u64,
8625    physical_lines: u64,
8626    files_analyzed: u64,
8627    files_skipped: u64,
8628    test_count: u64,
8629    project_label: String,
8630    html_url: Option<String>,
8631    has_pdf: bool,
8632    submodule_links: Vec<MetricsSubmoduleLink>,
8633    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
8634    #[serde(skip_serializing_if = "Option::is_none")]
8635    coverage_line_pct: Option<f64>,
8636}
8637
8638fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8639    let mut links: Vec<MetricsSubmoduleLink> = vec![];
8640    let sub_dir = e
8641        .html_path
8642        .as_ref()
8643        .and_then(|p| p.parent())
8644        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8645    let Some(dir) = sub_dir else { return links };
8646    let Ok(rd) = std::fs::read_dir(dir) else {
8647        return links;
8648    };
8649    for entry_res in rd.flatten() {
8650        let fname = entry_res.file_name();
8651        let fname_str = fname.to_string_lossy();
8652        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8653            let stem = &fname_str[..fname_str.len() - 5];
8654            let display = stem[4..].replace('-', " ");
8655            links.push(MetricsSubmoduleLink {
8656                name: display,
8657                url: format!("/runs/{stem}/{}", e.run_id),
8658            });
8659        }
8660    }
8661    links.sort_by(|a, b| a.name.cmp(&b.name));
8662    links
8663}
8664
8665fn apply_submodule_filter(
8666    base: MetricsHistoryEntry,
8667    filter: &str,
8668    e: &sloc_core::history::RegistryEntry,
8669) -> Option<MetricsHistoryEntry> {
8670    let json_path = e.json_path.as_ref()?;
8671    let json_str = std::fs::read_to_string(json_path).ok()?;
8672    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8673    let sub = run
8674        .submodule_summaries
8675        .iter()
8676        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8677    let safe = sanitize_project_label(&sub.name);
8678    let artifact_key = format!("sub_{safe}");
8679    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8680        || base.html_url.clone(),
8681        |run_dir| {
8682            let sub_path = run_dir.join(format!("{artifact_key}.html"));
8683            if sub_path.exists() {
8684                Some(format!("/runs/{artifact_key}/{}", e.run_id))
8685            } else {
8686                base.html_url.clone()
8687            }
8688        },
8689    );
8690
8691    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
8692    // basic SLOC totals, so test_count and coverage must be computed from file records.
8693    let sub_files: Vec<_> = run
8694        .per_file_records
8695        .iter()
8696        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8697        .collect();
8698    let test_count: u64 = sub_files
8699        .iter()
8700        .map(|r| r.raw_line_categories.test_count)
8701        .sum();
8702    #[allow(clippy::cast_precision_loss)]
8703    let coverage_line_pct: Option<f64> = {
8704        let found: u64 = sub_files
8705            .iter()
8706            .filter_map(|r| r.coverage.as_ref())
8707            .map(|c| u64::from(c.lines_found))
8708            .sum();
8709        let hit: u64 = sub_files
8710            .iter()
8711            .filter_map(|r| r.coverage.as_ref())
8712            .map(|c| u64::from(c.lines_hit))
8713            .sum();
8714        if found > 0 {
8715            let pct = (hit as f64 / found as f64) * 100.0;
8716            Some((pct * 10.0).round() / 10.0)
8717        } else {
8718            None
8719        }
8720    };
8721
8722    Some(MetricsHistoryEntry {
8723        code_lines: sub.code_lines,
8724        comment_lines: sub.comment_lines,
8725        blank_lines: sub.blank_lines,
8726        physical_lines: sub.total_physical_lines,
8727        files_analyzed: sub.files_analyzed,
8728        files_skipped: 0,
8729        test_count,
8730        html_url: sub_html_url,
8731        has_pdf: false,
8732        submodule_links: vec![],
8733        coverage_line_pct,
8734        ..base
8735    })
8736}
8737
8738#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
8739async fn api_metrics_history_handler(
8740    State(state): State<AppState>,
8741    Query(query): Query<MetricsHistoryQuery>,
8742) -> Response {
8743    let limit = query.limit.unwrap_or(50).min(500);
8744    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8745
8746    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8747        let reg = state.registry.lock().await;
8748        reg.entries
8749            .iter()
8750            .filter(|e| {
8751                query.root.as_ref().is_none_or(|root| {
8752                    let resolved = resolve_input_path(root);
8753                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8754                    e.input_roots.iter().any(|r| r == &root_str)
8755                })
8756            })
8757            .take(limit)
8758            .cloned()
8759            .collect()
8760    };
8761
8762    let entries: Vec<MetricsHistoryEntry> = candidate_entries
8763        .into_iter()
8764        .filter_map(|e| {
8765            let tags = e
8766                .git_tags
8767                .as_deref()
8768                .map(|s| {
8769                    s.split(',')
8770                        .map(|t| t.trim().to_string())
8771                        .filter(|t| !t.is_empty())
8772                        .collect()
8773                })
8774                .unwrap_or_default();
8775            let html_url = e
8776                .html_path
8777                .as_ref()
8778                .filter(|p| p.exists())
8779                .map(|_| format!("/runs/html/{}", e.run_id));
8780            let nearest_tag = e.git_nearest_tag.clone();
8781            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8782            let run_id_short: String = e
8783                .run_id
8784                .split('-')
8785                .next_back()
8786                .unwrap_or(&e.run_id)
8787                .chars()
8788                .take(7)
8789                .collect();
8790            let submodule_links = build_entry_submodule_links(&e);
8791            #[allow(clippy::cast_precision_loss)]
8792            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8793                let pct = (e.summary.coverage_lines_hit as f64
8794                    / e.summary.coverage_lines_found as f64)
8795                    * 100.0;
8796                Some((pct * 10.0).round() / 10.0)
8797            } else {
8798                None
8799            };
8800            let base = MetricsHistoryEntry {
8801                run_id: e.run_id.clone(),
8802                run_id_short,
8803                timestamp: e.timestamp_utc.to_rfc3339(),
8804                commit: e.git_commit.clone(),
8805                branch: e.git_branch.clone(),
8806                tags,
8807                nearest_tag,
8808                code_lines: e.summary.code_lines,
8809                comment_lines: e.summary.comment_lines,
8810                blank_lines: e.summary.blank_lines,
8811                physical_lines: e.summary.total_physical_lines,
8812                files_analyzed: e.summary.files_analyzed,
8813                files_skipped: e.summary.files_skipped,
8814                test_count: e.summary.test_count,
8815                project_label: e.project_label.clone(),
8816                html_url,
8817                has_pdf,
8818                submodule_links,
8819                coverage_line_pct,
8820            };
8821            if let Some(ref filter) = submodule_filter {
8822                apply_submodule_filter(base, filter, &e)
8823            } else {
8824                Some(base)
8825            }
8826        })
8827        .collect();
8828
8829    Json(entries).into_response()
8830}
8831
8832/// One scan's code churn versus the previous scan of the same project.
8833#[derive(Serialize)]
8834struct ChurnEntry {
8835    run_id: String,
8836    added: i64,
8837    removed: i64,
8838    modified: i64,
8839    unmodified: i64,
8840}
8841
8842// GET /api/metrics/churn?root=<path>&limit=<n>
8843// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
8844// comparing each scan to the previous scan of the same project. Loads per-file JSON
8845// artifacts, so it is intended for export-time use rather than every page load.
8846async fn api_metrics_churn_handler(
8847    State(state): State<AppState>,
8848    Query(query): Query<MetricsHistoryQuery>,
8849) -> Response {
8850    let limit = query.limit.unwrap_or(200).min(500);
8851    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8852        let reg = state.registry.lock().await;
8853        reg.entries
8854            .iter()
8855            .filter(|e| {
8856                query.root.as_ref().is_none_or(|root| {
8857                    let resolved = resolve_input_path(root);
8858                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8859                    e.input_roots.iter().any(|r| r == &root_str)
8860                })
8861            })
8862            .take(limit)
8863            .cloned()
8864            .collect()
8865    };
8866    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8867        std::collections::HashMap::new();
8868    for e in candidate_entries {
8869        by_project
8870            .entry(e.project_label.clone())
8871            .or_default()
8872            .push(e);
8873    }
8874    let mut out: Vec<ChurnEntry> = Vec::new();
8875    for (_proj, mut entries) in by_project {
8876        entries.sort_by_key(|e| e.timestamp_utc);
8877        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
8878        for e in &entries {
8879            let curr = e
8880                .json_path
8881                .as_ref()
8882                .and_then(|path| sloc_core::read_json(path).ok());
8883            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
8884                let cmp = sloc_core::compute_delta(prev, cur);
8885                out.push(ChurnEntry {
8886                    run_id: e.run_id.clone(),
8887                    added: sum_added_code_lines(&cmp),
8888                    removed: sum_removed_code_lines(&cmp),
8889                    modified: sum_modified_code_lines(&cmp),
8890                    unmodified: sum_unmodified_code_lines(&cmp),
8891                });
8892            } else {
8893                out.push(ChurnEntry {
8894                    run_id: e.run_id.clone(),
8895                    added: 0,
8896                    removed: 0,
8897                    modified: 0,
8898                    unmodified: 0,
8899                });
8900            }
8901            if curr.is_some() {
8902                prev_run = curr;
8903            }
8904        }
8905    }
8906    Json(out).into_response()
8907}
8908
8909// GET /api/metrics/submodules?root=<path>
8910// Returns the union of distinct submodule names found across all saved scan JSON artifacts
8911// for the given project root (or all roots if omitted).
8912#[derive(Deserialize)]
8913struct MetricsSubmodulesQuery {
8914    root: Option<String>,
8915}
8916
8917#[derive(Serialize)]
8918struct SubmoduleEntry {
8919    name: String,
8920    relative_path: String,
8921}
8922
8923async fn api_metrics_submodules_handler(
8924    State(state): State<AppState>,
8925    Query(query): Query<MetricsSubmodulesQuery>,
8926) -> Response {
8927    let json_paths: Vec<std::path::PathBuf> = {
8928        let reg = state.registry.lock().await;
8929        reg.entries
8930            .iter()
8931            .filter(|e| {
8932                query.root.as_ref().is_none_or(|root| {
8933                    let resolved = resolve_input_path(root);
8934                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8935                    e.input_roots.iter().any(|r| r == &root_str)
8936                })
8937            })
8938            .filter_map(|e| e.json_path.clone())
8939            .collect()
8940    };
8941
8942    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8943    let mut result: Vec<SubmoduleEntry> = Vec::new();
8944
8945    for path in &json_paths {
8946        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8947            continue;
8948        };
8949        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8950            continue;
8951        };
8952        for sub in &run.submodule_summaries {
8953            if seen.insert(sub.name.clone()) {
8954                result.push(SubmoduleEntry {
8955                    name: sub.name.clone(),
8956                    relative_path: sub.relative_path.clone(),
8957                });
8958            }
8959        }
8960    }
8961
8962    result.sort_by(|a, b| a.name.cmp(&b.name));
8963    Json(result).into_response()
8964}
8965
8966// ── CI ingest endpoint ────────────────────────────────────────────────────────
8967// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
8968// server stores and displays results without cloning or scanning anything itself.
8969//
8970// POST /api/ingest?label=<optional_display_name>
8971// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
8972// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
8973
8974#[derive(Deserialize)]
8975struct IngestQuery {
8976    label: Option<String>,
8977}
8978
8979#[derive(Serialize)]
8980struct IngestResponse {
8981    run_id: String,
8982    view_url: String,
8983}
8984
8985async fn api_ingest_handler(
8986    State(state): State<AppState>,
8987    Query(q): Query<IngestQuery>,
8988    Json(run): Json<sloc_core::AnalysisRun>,
8989) -> Response {
8990    let label = q.label.unwrap_or_else(|| {
8991        run.input_roots
8992            .first()
8993            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8994    });
8995
8996    let label_for_task = label.clone();
8997    let result = tokio::task::spawn_blocking(move || {
8998        let html = render_html(&run)?;
8999        let run_id = run.tool.run_id.clone();
9000        let run_id_safe = run_id.len() <= 128
9001            && !run_id.is_empty()
9002            && run_id
9003                .chars()
9004                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9005        if !run_id_safe {
9006            anyhow::bail!(
9007                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9008            );
9009        }
9010        let project_label = sanitize_project_label(&label_for_task);
9011        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9012        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9013            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9014            _ => project_label,
9015        };
9016        let (artifacts, _pending_pdf) = persist_run_artifacts(
9017            &run,
9018            &html,
9019            &output_dir,
9020            &label_for_task,
9021            &file_stem,
9022            RunResultContext::default(),
9023        )?;
9024        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9025    })
9026    .await;
9027
9028    match result {
9029        Ok(Ok((run_id, artifacts, run))) => {
9030            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9031            (
9032                StatusCode::CREATED,
9033                Json(IngestResponse {
9034                    view_url: format!("/view-reports?run_id={run_id}"),
9035                    run_id,
9036                }),
9037            )
9038                .into_response()
9039        }
9040        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9041        Err(e) => error::internal(&format!("{e}")),
9042    }
9043}
9044
9045// ── Multi-compare page ────────────────────────────────────────────────────────
9046// GET /multi-compare?runs=id1,id2,id3,...
9047
9048fn html_escape(s: &str) -> String {
9049    s.replace('&', "&amp;")
9050        .replace('<', "&lt;")
9051        .replace('>', "&gt;")
9052        .replace('"', "&quot;")
9053}
9054
9055#[allow(clippy::cast_precision_loss)]
9056fn fmt_num(n: i64) -> String {
9057    let a = n.unsigned_abs();
9058    if a >= 1_000_000 {
9059        let v = n as f64 / 1_000_000.0;
9060        let s = format!("{v:.1}");
9061        format!("{}M", s.trim_end_matches(".0"))
9062    } else if a >= 10_000 {
9063        let v = n as f64 / 1_000.0;
9064        let s = format!("{v:.1}");
9065        format!("{}K", s.trim_end_matches(".0"))
9066    } else {
9067        let sign = if n < 0 { "-" } else { "" };
9068        if a < 1_000 {
9069            return format!("{sign}{a}");
9070        }
9071        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9072    }
9073}
9074
9075fn fmt_comma(n: i64) -> String {
9076    let sign = if n < 0 { "-" } else { "" };
9077    let a = n.unsigned_abs();
9078    if a < 1_000 {
9079        return format!("{sign}{a}");
9080    }
9081    let s = a.to_string();
9082    let bytes = s.as_bytes();
9083    let len = bytes.len();
9084    let mut out = String::with_capacity(len + len / 3);
9085    for (i, &b) in bytes.iter().enumerate() {
9086        if i > 0 && (len - i).is_multiple_of(3) {
9087            out.push(',');
9088        }
9089        out.push(b as char);
9090    }
9091    format!("{sign}{out}")
9092}
9093
9094/// Insert thousands separators into the integer portion of a number's textual form.
9095///
9096/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9097/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9098/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9099/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9100fn group_thousands(s: &str) -> String {
9101    let (sign, rest) = match s.as_bytes().first() {
9102        Some(b'-') => ("-", &s[1..]),
9103        Some(b'+') => ("+", &s[1..]),
9104        _ => ("", s),
9105    };
9106    let (int_part, frac_part) = match rest.split_once('.') {
9107        Some((i, f)) => (i, Some(f)),
9108        None => (rest, None),
9109    };
9110    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9111        return s.to_string();
9112    }
9113    let bytes = int_part.as_bytes();
9114    let len = bytes.len();
9115    let mut grouped = String::with_capacity(len + len / 3);
9116    for (i, &b) in bytes.iter().enumerate() {
9117        if i > 0 && (len - i).is_multiple_of(3) {
9118            grouped.push(',');
9119        }
9120        grouped.push(b as char);
9121    }
9122    frac_part.map_or_else(
9123        || format!("{sign}{grouped}"),
9124        |f| format!("{sign}{grouped}.{f}"),
9125    )
9126}
9127
9128/// Custom Askama filters available to templates in this crate.
9129mod filters {
9130    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9131    // (a `&self` `execute` method returning `Result`), not on our own source.
9132    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9133    use askama::{Result, Values};
9134
9135    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9136    ///
9137    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9138    /// (dashes, "No prior scan", etc.) passes through untouched.
9139    #[askama::filter_fn]
9140    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9141        Ok(super::group_thousands(&value.to_string()))
9142    }
9143}
9144
9145#[derive(Deserialize, Default)]
9146struct MultiCompareQuery {
9147    runs: Option<String>,
9148    /// "super" to show only super-repo files (exclude all submodule files)
9149    scope: Option<String>,
9150    /// Submodule name to narrow the comparison to one submodule
9151    sub: Option<String>,
9152}
9153
9154#[allow(clippy::too_many_lines)]
9155async fn multi_compare_handler(
9156    State(state): State<AppState>,
9157    Query(params): Query<MultiCompareQuery>,
9158    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9159) -> impl IntoResponse {
9160    let run_ids: Vec<String> = params
9161        .runs
9162        .as_deref()
9163        .unwrap_or("")
9164        .split(',')
9165        .map(|s| s.trim().to_string())
9166        .filter(|s| !s.is_empty())
9167        .collect();
9168
9169    if run_ids.len() < 2 {
9170        return Html(
9171            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9172             <a href=\"/compare-scans\">Go back</a></p>",
9173        )
9174        .into_response();
9175    }
9176    if run_ids.len() > 20 {
9177        return Html(
9178            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9179             at once. <a href=\"/compare-scans\">Go back</a></p>",
9180        )
9181        .into_response();
9182    }
9183
9184    // Look up each run_id in the registry.
9185    let entries: Vec<Option<RegistryEntry>> = {
9186        let reg = state.registry.lock().await;
9187        run_ids
9188            .iter()
9189            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9190            .collect()
9191    };
9192
9193    for (i, entry) in entries.iter().enumerate() {
9194        if entry.is_none() {
9195            let html = format!(
9196                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9197                 found. <a href=\"/compare-scans\">Go back</a></p>",
9198                run_ids[i]
9199            );
9200            return Html(html).into_response();
9201        }
9202    }
9203
9204    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9205
9206    for entry in &entries {
9207        if entry.json_path.is_none() {
9208            let html = format!(
9209                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9210                 JSON data — re-run the analysis to enable comparison. \
9211                 <a href=\"/compare-scans\">Go back</a></p>",
9212                &entry.run_id
9213            );
9214            return Html(html).into_response();
9215        }
9216    }
9217
9218    // Sort chronologically.
9219    entries.sort_by_key(|e| e.timestamp_utc);
9220
9221    // Load JSON for each entry.
9222    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9223    for entry in &entries {
9224        let path = entry.json_path.as_ref().unwrap();
9225        match read_json(path) {
9226            Ok(r) => runs.push(r),
9227            Err(e) => {
9228                let html = format!(
9229                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9230                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9231                    &entry.run_id
9232                );
9233                return Html(html).into_response();
9234            }
9235        }
9236    }
9237
9238    // Collect submodule names from all runs.
9239    let all_sub_names: Vec<String> = {
9240        let mut set = std::collections::BTreeSet::new();
9241        for r in &runs {
9242            for s in &r.submodule_summaries {
9243                set.insert(s.name.clone());
9244            }
9245        }
9246        set.into_iter().collect()
9247    };
9248    let has_submodule_data = !all_sub_names.is_empty();
9249    let active_submodule = params.sub.clone();
9250    let super_scope_active = params.scope.as_deref() == Some("super");
9251
9252    // Narrow per_file_records when a scope is active, then recompute totals.
9253    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9254
9255    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9256    let project_label = entries
9257        .first()
9258        .map_or("", |e| e.project_label.as_str())
9259        .to_string();
9260    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9261    let multi = compute_multi_delta(&run_refs);
9262    let html = multi_compare_page(
9263        &multi,
9264        &project_label,
9265        env!("CARGO_PKG_VERSION"),
9266        &csp_nonce,
9267        has_submodule_data,
9268        &all_sub_names,
9269        &runs_csv,
9270        super_scope_active,
9271        active_submodule.as_deref(),
9272        &entries,
9273    );
9274    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9275    // copy after a rebuild would silently mask UI fixes.
9276    (
9277        [(axum::http::header::CACHE_CONTROL, "no-store")],
9278        Html(html),
9279    )
9280        .into_response()
9281}
9282
9283const fn multi_delta_class(n: i64) -> &'static str {
9284    match n {
9285        1.. => "pos",
9286        ..=-1 => "neg",
9287        0 => "zero",
9288    }
9289}
9290
9291fn multi_fmt_delta(n: i64) -> String {
9292    if n > 0 {
9293        format!("+{n}")
9294    } else {
9295        format!("{n}")
9296    }
9297}
9298
9299/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9300fn js_escape(s: &str) -> String {
9301    use std::fmt::Write as _;
9302    let mut out = String::with_capacity(s.len() + 2);
9303    for c in s.chars() {
9304        match c {
9305            '"' => out.push_str("\\\""),
9306            '\\' => out.push_str("\\\\"),
9307            '\n' => out.push_str("\\n"),
9308            '\r' => out.push_str("\\r"),
9309            '\t' => out.push_str("\\t"),
9310            c if (c as u32) < 0x20 => {
9311                let _ = write!(out, "\\u{:04x}", c as u32);
9312            }
9313            c => out.push(c),
9314        }
9315    }
9316    out
9317}
9318
9319/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9320fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9321    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9322        return (
9323            "&mdash;".to_string(),
9324            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9325        );
9326    };
9327    let cd = entry
9328        .git_commit_date
9329        .as_deref()
9330        .and_then(fmt_git_date)
9331        .unwrap_or_else(|| "&mdash;".to_string());
9332    let au = entry.git_author.as_deref().map_or_else(
9333        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9334        |a| {
9335            format!(
9336                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9337                 <span class=\"cmp-author-handle\"></span></span>",
9338                html_escape(a)
9339            )
9340        },
9341    );
9342    (cd, au)
9343}
9344
9345/// Render the scope badge chip for a scan card header.
9346fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9347    active_sub.map_or_else(
9348        || {
9349            if super_scope_active {
9350                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9351            } else {
9352                "<span class=\"mc-scope-tag mc-scope-full\">\
9353                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9354                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9355                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9356                 <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\
9357                 </svg> Full scan</span>"
9358                    .to_string()
9359            }
9360        },
9361        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9362    )
9363}
9364
9365/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9366fn build_mc_scan_strip(
9367    multi: &MultiScanComparison,
9368    entries: &[RegistryEntry],
9369    n: usize,
9370    is_many: bool,
9371    active_sub: Option<&str>,
9372    super_scope_active: bool,
9373    project_label: &str,
9374) -> String {
9375    use std::fmt::Write as _;
9376    let mut scan_strip = String::new();
9377    for (i, pt) in multi.points.iter().enumerate() {
9378        let ts_ms = pt.timestamp.timestamp_millis();
9379        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9380        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9381        let branch = pt.git_branch.as_deref().unwrap_or("");
9382        let report_link = format!("/runs/html/{}", pt.run_id);
9383        let branch_html = if branch.is_empty() {
9384            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9385        } else {
9386            format!(
9387                "<span class=\"mc-card-branch\">{}</span>",
9388                html_escape(branch)
9389            )
9390        };
9391        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9392        let tags_html = pt
9393            .git_tags
9394            .as_deref()
9395            .filter(|t| !t.is_empty())
9396            .map(|t| {
9397                let chips = t
9398                    .split(',')
9399                    .filter(|s| !s.is_empty())
9400                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9401                    .collect::<Vec<_>>()
9402                    .join(" ");
9403                format!(
9404                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9405                     <span class=\"mc-row-val\">{chips}</span></div>"
9406                )
9407            })
9408            .unwrap_or_default();
9409        let nearest = pt
9410            .git_nearest_tag
9411            .as_deref()
9412            .map(|t| format!("near {}", html_escape(t)))
9413            .unwrap_or_default();
9414        let arrow = if i < n - 1 && !is_many {
9415            "<div class='mc-arrow'>&#8594;</div>"
9416        } else {
9417            ""
9418        };
9419        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9420        let nearest_html = if nearest.is_empty() {
9421            String::new()
9422        } else {
9423            format!(
9424                "<span class=\"mc-card-nearest-wrap\">\
9425                 <span class=\"mc-card-nearest\">{nearest}</span>\
9426                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9427                 </span>"
9428            )
9429        };
9430        write!(
9431            scan_strip,
9432            r#"<div class="mc-card">
9433              <div class="mc-card-header">
9434                <div class="mc-card-num">Scan {num}</div>
9435                <div class="mc-card-project-col">
9436                  <div class="mc-card-project">{project_label}</div>
9437                  {scope_badge}
9438                </div>
9439              </div>
9440              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9441              <div class="mc-card-rows">
9442                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9443                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9444                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9445                <div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
9446                {tags_html}
9447              </div>
9448              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9449            </div>{arrow}"#,
9450            num = i + 1,
9451            commit = html_escape(commit),
9452            commit_date = commit_date_html,
9453            ts_ms = ts_ms,
9454            code = fmt_num(pt.code_lines),
9455            scope_badge = scope_badge,
9456            nearest_html = nearest_html,
9457        )
9458        .unwrap();
9459    }
9460    scan_strip
9461}
9462
9463/// Build the metric progression table (thead + tbody) for multi-compare.
9464#[allow(clippy::too_many_lines)]
9465fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9466    use std::fmt::Write as _;
9467    struct MetricRow<'a> {
9468        label: &'a str,
9469        values: Vec<i64>,
9470        seq_deltas: Vec<i64>,
9471        net_delta: i64,
9472    }
9473    let rows: Vec<MetricRow<'_>> = vec![
9474        MetricRow {
9475            label: "Code Lines",
9476            values: multi.points.iter().map(|p| p.code_lines).collect(),
9477            seq_deltas: multi
9478                .sequential_deltas
9479                .iter()
9480                .map(|d| d.summary.code_lines_delta)
9481                .collect(),
9482            net_delta: multi.total_delta.code_lines_delta,
9483        },
9484        MetricRow {
9485            label: "Files Analyzed",
9486            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9487            seq_deltas: multi
9488                .sequential_deltas
9489                .iter()
9490                .map(|d| d.summary.files_analyzed_delta)
9491                .collect(),
9492            net_delta: multi.total_delta.files_analyzed_delta,
9493        },
9494        MetricRow {
9495            label: "Comment Lines",
9496            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9497            seq_deltas: multi
9498                .sequential_deltas
9499                .iter()
9500                .map(|d| d.summary.comment_lines_delta)
9501                .collect(),
9502            net_delta: multi.total_delta.comment_lines_delta,
9503        },
9504        MetricRow {
9505            label: "Blank Lines",
9506            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9507            seq_deltas: multi
9508                .sequential_deltas
9509                .iter()
9510                .map(|d| d.summary.blank_lines_delta)
9511                .collect(),
9512            net_delta: multi.total_delta.blank_lines_delta,
9513        },
9514        MetricRow {
9515            label: "Tests",
9516            values: multi.points.iter().map(|p| p.test_count).collect(),
9517            seq_deltas: multi
9518                .points
9519                .windows(2)
9520                .map(|pts| pts[1].test_count - pts[0].test_count)
9521                .collect(),
9522            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9523                - multi.points.first().map_or(0, |f| f.test_count),
9524        },
9525    ];
9526    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9527    for i in 0..n {
9528        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9529        if i < n - 1 {
9530            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9531        }
9532    }
9533    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9534    let mut metrics_tbody = String::new();
9535    for row in &rows {
9536        metrics_tbody.push_str("<tr>");
9537        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9538        for i in 0..n {
9539            write!(
9540                metrics_tbody,
9541                "<td class='mc-val-col'>{}</td>",
9542                fmt_comma(row.values[i])
9543            )
9544            .unwrap();
9545            if i < n - 1 {
9546                let d = row.seq_deltas[i];
9547                write!(
9548                    metrics_tbody,
9549                    "<td class='mc-delta-col {cls}'>{val}</td>",
9550                    cls = multi_delta_class(d),
9551                    val = multi_fmt_delta(d)
9552                )
9553                .unwrap();
9554            }
9555        }
9556        let nd = row.net_delta;
9557        write!(
9558            metrics_tbody,
9559            "<td class='mc-net-col {cls}'>{val}</td>",
9560            cls = multi_delta_class(nd),
9561            val = multi_fmt_delta(nd)
9562        )
9563        .unwrap();
9564        metrics_tbody.push_str("</tr>");
9565    }
9566    (metrics_thead, metrics_tbody)
9567}
9568
9569/// Build the JS-embeddable points JSON array for the multi-compare chart.
9570fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9571    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9572    for (i, pt) in multi.points.iter().enumerate() {
9573        let commit = pt.git_commit.as_deref().unwrap_or("");
9574        let branch = pt.git_branch.as_deref().unwrap_or("");
9575        let tags = pt.git_tags.as_deref().unwrap_or("");
9576        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9577        let scanned_ms = pt.timestamp.timestamp_millis();
9578        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9579        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9580        let commit_date = entry
9581            .and_then(|e| e.git_commit_date.as_deref())
9582            .and_then(fmt_git_date)
9583            .unwrap_or_default();
9584        let author = entry
9585            .and_then(|e| e.git_author.as_deref())
9586            .unwrap_or("")
9587            .to_string();
9588        let cov = pt
9589            .coverage_line_pct
9590            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9591        parts.push(format!(
9592            r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
9593            run_id = js_escape(&pt.run_id),
9594            commit = js_escape(commit),
9595            branch = js_escape(branch),
9596            tags = js_escape(tags),
9597            nearest = js_escape(nearest),
9598            commit_date = js_escape(&commit_date),
9599            author = js_escape(&author),
9600            scanned = js_escape(&scanned),
9601            code = pt.code_lines,
9602            comments = pt.comment_lines,
9603            blank = pt.blank_lines,
9604            files = pt.files_analyzed,
9605            tests = pt.test_count,
9606        ));
9607    }
9608    format!("[{}]", parts.join(","))
9609}
9610
9611/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
9612fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9613    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9614    for row in &multi.file_matrix {
9615        let lang = row.language.as_deref().unwrap_or("");
9616        let codes: Vec<String> = row
9617            .code_per_scan
9618            .iter()
9619            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9620            .collect();
9621        let deltas: Vec<String> = row
9622            .code_delta_per_scan
9623            .iter()
9624            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9625            .collect();
9626        parts.push(format!(
9627            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9628            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9629            status = row.overall_status,
9630            codes = codes.join(","),
9631            deltas = deltas.join(","),
9632            total = row.total_code_delta,
9633        ));
9634    }
9635    format!("[{}]", parts.join(","))
9636}
9637
9638/// Build the column header cells for the file-matrix table.
9639fn build_mc_file_col_headers(n: usize) -> String {
9640    use std::fmt::Write as _;
9641    let mut out = String::new();
9642    for i in 0..n {
9643        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9644        if i < n - 1 {
9645            write!(
9646                out,
9647                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
9648                i + 2
9649            )
9650            .unwrap();
9651        }
9652    }
9653    out
9654}
9655
9656/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
9657fn build_mc_scope_bar(
9658    has_submodule_data: bool,
9659    sub_names: &[String],
9660    runs_csv: &str,
9661    active_sub: Option<&str>,
9662    super_scope_active: bool,
9663) -> String {
9664    use std::fmt::Write as _;
9665    if !has_submodule_data {
9666        return String::new();
9667    }
9668    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9669    let full_active = active_sub.is_none() && !super_scope_active;
9670    let mut bar = format!(
9671        r#"<div class="submod-scope-bar">
9672  <span class="submod-scope-label">
9673    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
9674    Scope:
9675  </span>
9676  <div class="submod-scope-divider"></div>
9677  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9678  <a class="submod-scope-btn{super_cls}" href="{base_url}&amp;scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
9679        full_cls = if full_active { " active" } else { "" },
9680        super_cls = if super_scope_active { " active" } else { "" },
9681    );
9682    for s in sub_names {
9683        let is_active = active_sub == Some(s.as_str());
9684        write!(
9685            bar,
9686            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9687            cls = if is_active { " active" } else { "" },
9688            name_enc = html_escape(s),
9689            name_esc = html_escape(s),
9690        )
9691        .unwrap();
9692    }
9693    bar.push_str("\n</div>");
9694    bar
9695}
9696
9697/// Build the scope-description label shown in the page subtitle.
9698fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9699    active_sub.map_or_else(
9700        || {
9701            if super_scope_active {
9702                "Super-repo only &mdash; ".to_string()
9703            } else {
9704                String::new()
9705            }
9706        },
9707        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
9708    )
9709}
9710
9711#[allow(clippy::too_many_lines)]
9712#[allow(clippy::too_many_arguments)]
9713fn multi_compare_page(
9714    multi: &MultiScanComparison,
9715    project_label: &str,
9716    version: &str,
9717    csp_nonce: &str,
9718    has_submodule_data: bool,
9719    sub_names: &[String],
9720    runs_csv: &str,
9721    super_scope_active: bool,
9722    active_sub: Option<&str>,
9723    entries: &[RegistryEntry],
9724) -> String {
9725    let n = multi.points.len();
9726    let is_many = n > 4;
9727    let mc_strip_class = if is_many {
9728        "mc-strip mc-strip-grid"
9729    } else {
9730        "mc-strip"
9731    };
9732
9733    // ── Scan strip cards ──────────────────────────────────────────────────────
9734    let scan_strip = build_mc_scan_strip(
9735        multi,
9736        entries,
9737        n,
9738        is_many,
9739        active_sub,
9740        super_scope_active,
9741        project_label,
9742    );
9743
9744    // ── Summary metrics table ─────────────────────────────────────────────────
9745    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9746
9747    // ── Chart data and table helpers ──────────────────────────────────────────
9748    let points_json = build_mc_points_json(multi, entries);
9749    let file_matrix_json = build_mc_file_matrix_json(multi);
9750
9751    // Counts for filter tabs
9752    let files_modified = multi
9753        .file_matrix
9754        .iter()
9755        .filter(|f| f.overall_status == "modified")
9756        .count();
9757    let files_added = multi
9758        .file_matrix
9759        .iter()
9760        .filter(|f| f.overall_status == "added")
9761        .count();
9762    let files_removed = multi
9763        .file_matrix
9764        .iter()
9765        .filter(|f| f.overall_status == "removed")
9766        .count();
9767    let files_unchanged = multi
9768        .file_matrix
9769        .iter()
9770        .filter(|f| f.overall_status == "unchanged")
9771        .count();
9772    let total_files = multi.file_matrix.len();
9773
9774    let file_col_headers = build_mc_file_col_headers(n);
9775    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9776    let scope_bar_html = build_mc_scope_bar(
9777        has_submodule_data,
9778        sub_names,
9779        runs_csv,
9780        active_sub,
9781        super_scope_active,
9782    );
9783    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9784    let toast_assets = sloc_toast_assets(csp_nonce);
9785
9786    format!(
9787        r#"<!doctype html>
9788<html lang="en">
9789<head>
9790  <meta charset="utf-8">
9791  <meta name="viewport" content="width=device-width, initial-scale=1">
9792  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9793  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9794  <style nonce="{csp_nonce}">
9795    :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
9796    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9797    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9798    body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
9799    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9800    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9801    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9802    .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
9803    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
9804    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
9805    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}}
9806    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9807    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9808    @media(max-width:1150px){{.nav-right{{gap:4px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 8px;font-size:11px;min-height:34px;}}.brand-subtitle{{display:none;}}}}
9809    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9810    .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
9811    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9812    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9813    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9814    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9815    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
9816    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9817    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9818    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9819    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9820    .nav-dropdown{{position:relative;display:inline-flex;}}
9821    .nav-dropdown-btn{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
9822    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9823    .nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity .13s,visibility 0s .13s;}}
9824    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9825    .nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}
9826    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9827    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9828    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9829    body:not(.dark-theme) .icon-sun{{display:none;}}
9830    body.dark-theme .icon-moon{{display:none;}}
9831    .settings-modal{{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
9832    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9833    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
9834    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
9835    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9836    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9837    .settings-modal-body{{padding:14px 16px 16px;}}
9838    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9839    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9840    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
9841    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9842    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9843    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9844    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9845    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
9846    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9847    .btn-back{{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s;white-space:nowrap;margin-bottom:16px;}}
9848    .btn-back:hover{{background:var(--line);}}
9849    .mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
9850    body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
9851    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9852    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9853    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9854    .mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
9855    .mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
9856    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9857    .mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
9858    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9859    body.dark-theme .mc-card{{background:var(--surface-2);}}
9860    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9861    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9862    .mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
9863    .mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
9864    .mc-card-commit:hover{{color:var(--oxide);}}
9865    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9866    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9867    .mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
9868    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9869    .mc-card-branch{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);font-weight:700;display:inline-block;}}
9870    .mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
9871    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9872    .mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
9873    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
9874    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
9875    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
9876    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
9877    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
9878    .mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
9879    .mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
9880    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
9881    .mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
9882    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
9883    .submod-scope-bar{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:0 0 16px;}}
9884    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
9885    .submod-scope-label{{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}}
9886    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
9887    .submod-scope-btn{{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s,border-color .12s,color .12s;}}
9888    .submod-scope-btn:hover{{background:var(--line);}}
9889    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9890    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
9891    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
9892    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
9893    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9894    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
9895    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
9896    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
9897    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
9898    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
9899    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
9900    .metrics-table .pos{{color:var(--pos);}}
9901    .metrics-table .neg{{color:var(--neg);}}
9902    .metrics-table .zero{{color:var(--muted);}}
9903    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
9904    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9905    .chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
9906    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9907    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
9908    .chart-wrap{{width:100%;overflow-x:auto;}}
9909    #mc-chart{{display:block;width:100%;}}
9910    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
9911    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
9912    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
9913    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
9914    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9915    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
9916    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
9917    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
9918    .ic-card-h2-row .ic-card-h2{{margin:0;}}
9919    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9920    .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
9921    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9922    .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
9923    .ic-svg-modal-ov.open{{display:flex;}}
9924    .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
9925    .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
9926    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
9927    .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
9928    .ic-svg-modal-close:hover{{background:var(--line);}}
9929    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
9930    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
9931    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
9932    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
9933    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
9934    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
9935    #mc-ic-tt{{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);max-width:240px;white-space:nowrap;}}
9936    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9937    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
9938    .tab-btn{{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s;}}
9939    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9940    .tab-btn:hover:not(.active){{background:var(--line);}}
9941    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
9942    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
9943    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
9944    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
9945    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
9946    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
9947    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
9948    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
9949    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
9950    .table-wrap{{width:100%;overflow-x:auto;}}
9951    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
9952    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
9953    #file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
9954    #file-table th.left,#file-table td.left{{text-align:left;}}
9955    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
9956    .file-delta-col{{color:var(--muted);font-size:11px;}}
9957    .file-net-col{{font-weight:800;}}
9958    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
9959    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
9960    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
9961    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
9962    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
9963    .status-badge.modified{{background:#fff2d8;color:#926000;}}
9964    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
9965    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
9966    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
9967    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
9968    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
9969    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
9970    tr.row-added td{{background:rgba(26,143,71,0.04);}}
9971    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9972    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9973    tr.row-unchanged td{{color:var(--muted);}}
9974    tr.row-unchanged .status-badge{{opacity:.65;}}
9975    .file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
9976    .absent{{color:var(--muted);font-style:italic;}}
9977    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9978    .pagination-info{{font-size:12px;color:var(--muted);}}
9979    .pagination-btns{{display:flex;gap:5px;}}
9980    .pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
9981    .pg-btn:hover:not(:disabled){{background:var(--line);}}
9982    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9983    .pg-btn:disabled{{opacity:.35;cursor:default;}}
9984    select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
9985    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s;}}
9986    .export-btn:hover{{background:var(--line);}}
9987    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
9988    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9989    .site-footer a{{color:var(--muted);}}
9990    body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
9991    body.pdf-mode{{background:#fff!important;}}
9992    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
9993    .mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
9994    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9995    .mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
9996    .mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
9997    .mc-modal-title{{font-size:18px;font-weight:800;}}
9998    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9999    .mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
10000    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10001    .mc-modal-body{{padding:18px 22px;}}
10002    .mc-modal-sec{{margin-bottom:20px;}}
10003    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10004    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10005    .mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
10006    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10007    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10008    .mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10009    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10010    .mc-modal-row:last-child{{border-bottom:none;}}
10011    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10012    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10013    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10014    .mc-modal-val a:hover{{text-decoration:underline;}}
10015    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10016    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10017    .mc-modal-stat[data-tip]{{cursor:help;}}
10018    #mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
10019    .mc-card{{cursor:pointer;}}
10020    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10021  </style>
10022</head>
10023<body>
10024  {loading_overlay}
10025  <div class="background-watermarks" aria-hidden="true">
10026    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10027    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10028    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10029    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10030    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10031    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10032  </div>
10033  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10034  <div class="top-nav">
10035    <div class="top-nav-inner">
10036      <a class="brand" href="/">
10037        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10038        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10039      </a>
10040      <div class="nav-right">
10041        <a class="nav-pill" href="/">Home</a>
10042        <div class="nav-dropdown">
10043          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10044          <div class="nav-dropdown-menu">
10045            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
10046          </div>
10047        </div>
10048        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10049        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10050        <div class="nav-dropdown">
10051          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10052          <div class="nav-dropdown-menu">
10053            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
10054          </div>
10055        </div>
10056        <div class="server-status-wrap" id="server-status-wrap">
10057          <div class="nav-pill server-online-pill" id="server-status-pill">
10058            <span class="status-dot" id="status-dot"></span>
10059            <span id="server-status-label">Server</span>
10060            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10061          </div>
10062          <div class="server-status-tip">
10063            OxideSLOC is running &mdash; accessible on your network.
10064            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10065          </div>
10066        </div>
10067        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10068          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
10069        </button>
10070        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10071          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
10072          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
10073        </button>
10074      </div>
10075    </div>
10076  </div>
10077
10078  <div class="page">
10079    <!-- Hero header -->
10080    <div class="mc-hero">
10081      <div class="mc-hero-header">
10082        <div>
10083          <div class="mc-title">Multi-Scan Timeline</div>
10084          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10085          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10086        </div>
10087        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10088          <a class="btn-back" href="/compare-scans"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg> Compare Scans</a>
10089          <div class="export-group" id="mc-top-export-group">
10090            <button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
10091            <button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
10092          </div>
10093        </div>
10094      </div>
10095      {scope_bar_html}
10096      <!-- Scan strip -->
10097      <div class="{mc_strip_class}">{scan_strip}</div>
10098    </div>
10099
10100    <!-- Summary metrics table -->
10101    <div class="panel">
10102      <div class="panel-title">Metric Progression</div>
10103      <div class="table-wrap">
10104        <table class="metrics-table">
10105          <thead>{metrics_thead}</thead>
10106          <tbody>{metrics_tbody}</tbody>
10107        </table>
10108      </div>
10109    </div>
10110
10111    <!-- Scan Charts -->
10112    <div class="panel" id="mc-charts-panel">
10113      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10114      <div class="ic-grid">
10115        <!-- Timeline line chart — spans full width -->
10116        <div class="ic-card" style="grid-column:span 2">
10117          <div class="ic-card-h2-row">
10118            <span class="ic-card-h2">Timeline</span>
10119            <div class="chart-toolbar" style="margin:0">
10120              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10121              <button class="chart-metric-btn" data-metric="files">Files</button>
10122              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10123              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10124              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10125            </div>
10126          </div>
10127          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10128        </div>
10129        <!-- Code Metrics: Scan 1 vs Latest -->
10130        <div class="ic-card">
10131          <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics &mdash; Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">&#x2922; Full View</button></div>
10132          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;scan&nbsp;1)</span></div>
10133          <div id="mc-ic-c1"></div>
10134        </div>
10135        <!-- Language Code Delta -->
10136        <div class="ic-card" id="mc-ic-lang-card">
10137          <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">&#x2922; Full View</button></div>
10138          <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10139          <div id="mc-ic-c3"></div>
10140        </div>
10141        <!-- Delta by Metric -->
10142        <div class="ic-card">
10143          <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">&#x2922; Full View</button></div>
10144          <div id="mc-ic-c2"></div>
10145        </div>
10146        <!-- File Change Distribution -->
10147        <div class="ic-card">
10148          <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">&#x2922; Full View</button></div>
10149          <div id="mc-ic-c4"></div>
10150        </div>
10151      </div>
10152    </div>
10153
10154    <!-- File matrix table -->
10155    <div class="panel">
10156      <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10157      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10158        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10159          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10160          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10161          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10162          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10163          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10164        </div>
10165        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10166          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10167          <div class="export-group">
10168          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10169          <button type="button" class="export-btn" id="export-csv-btn">
10170            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10171            CSV
10172          </button>
10173          <button type="button" class="export-btn" id="mc-file-xls-btn">
10174            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10175            Excel
10176          </button>
10177          </div>
10178        </div>
10179      </div>
10180      <div class="table-wrap">
10181        <table id="file-table">
10182          <thead>
10183            <tr>
10184              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10185              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10186              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10187              {file_col_headers}
10188              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10189            </tr>
10190          </thead>
10191          <tbody id="file-tbody"></tbody>
10192        </table>
10193      </div>
10194      <div class="pagination">
10195        <span class="pagination-info" id="pg-info"></span>
10196        <div class="pagination-btns" id="pg-btns"></div>
10197        <div style="display:flex;align-items:center;gap:6px;">
10198          <span style="font-size:12px;color:var(--muted)">Show</span>
10199          <select class="per-page" id="per-page-sel">
10200            <option value="25" selected>25 per page</option>
10201            <option value="50">50 per page</option>
10202            <option value="100">100 per page</option>
10203          </select>
10204        </div>
10205      </div>
10206    </div>
10207  </div>
10208
10209  <div id="mc-ic-tt"></div>
10210
10211  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10212    <div class="ic-svg-modal">
10213      <div class="ic-svg-modal-hdr">
10214        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10215        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10216      </div>
10217      <div id="ic-svg-modal-body"></div>
10218    </div>
10219  </div>
10220
10221  <footer class="site-footer">
10222    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10223    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10224    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10225    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10226    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10227  </footer>
10228
10229  <script nonce="{csp_nonce}">
10230  (function(){{
10231    // ── Dark theme ───────────────────────────────────────────────────────────
10232    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10233    var renderInlineCharts=null;
10234    var tt=document.getElementById('theme-toggle');
10235    if(tt)tt.addEventListener('click',function(){{
10236      var on=document.body.classList.toggle('dark-theme');
10237      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10238      renderChart(activeMetric);
10239      if(renderInlineCharts)renderInlineCharts();
10240    }});
10241
10242    // ── Code particles ───────────────────────────────────────────────────────
10243    var container=document.getElementById('code-particles');
10244    if(container){{
10245      var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10246      for(var i=0;i<28;i++){{
10247        (function(idx){{
10248          var el=document.createElement('span');el.className='code-particle';
10249          el.textContent=snips[idx%snips.length];
10250          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10251          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10252          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10253          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10254          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10255          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10256          container.appendChild(el);
10257        }})(i);
10258      }}
10259    }}
10260
10261    // ── Watermarks ───────────────────────────────────────────────────────────
10262    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10263    if(wms.length){{
10264      var placed=[];
10265      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
10266      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
10267      var half=Math.floor(wms.length/2);
10268      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
10269    }}
10270
10271    // ── Settings / colour scheme modal ───────────────────────────────────────
10272    (function(){{
10273      var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
10274      function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
10275      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10276      function init(){{
10277        var btn=document.getElementById('settings-btn');if(!btn)return;
10278        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10279        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
10280        document.body.appendChild(m);
10281        var g=document.getElementById('scheme-grid');
10282        if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
10283        var cl=document.getElementById('settings-close-btn');
10284        btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
10285        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10286        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10287      }}
10288      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10289    }})();
10290
10291    // ── Timezone support for scan timestamps ─────────────────────────────────
10292    (function(){{
10293      window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};
10294      window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10295      window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('.mc-ts-local[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10296      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10297      window.applyTz(storedTz);
10298      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
10299      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10300    }})();
10301
10302    // ── Data ────────────────────────────────────────────────────────────────
10303    var POINTS={points_json};
10304    var FILES={file_matrix_json};
10305    var N={n};
10306
10307    // ── fmt helper ───────────────────────────────────────────────────────────
10308    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
10309    function fmtFull(n){{return Number(n).toLocaleString();}}
10310    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10311
10312    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10313    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10314    function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
10315    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10316    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10317
10318    // ── Timeline chart ───────────────────────────────────────────────────────
10319    var activeMetric='code';
10320    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10321    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10322
10323    function renderChart(metric){{
10324      var svg=document.getElementById('mc-chart');if(!svg)return;
10325      var W=svg.getBoundingClientRect().width||800,H=280;
10326      svg.setAttribute('height',H);
10327      var pad={{l:62,r:20,t:32,b:72}};
10328      var dark=document.body.classList.contains('dark-theme');
10329      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10330      var valid=pts.filter(function(v){{return v!=null;}});
10331      if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
10332      var minV=0,maxV=Math.max.apply(null,valid);
10333      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10334      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10335      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10336      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10337      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10338      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10339      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10340      var parts=[];
10341      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10342      for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
10343      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10344      var lineD='';var firstPt=true;
10345      for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
10346      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10347      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10348      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10349      for(var i=0;i<N;i++){{
10350        if(pts[i]==null)continue;
10351        var cx=xOf(i),cy=yOf(pts[i]);
10352        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10353        var hasTag=p.tags&&p.tags.length>0;
10354        // Permanent Y-value label above the dot
10355        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10356        parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10357        var xanchor=i===0?'start':i===N-1?'end':'middle';
10358        // X-axis label at 2× the original size (18 px)
10359        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
10360      }}
10361      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10362      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10363      svg.innerHTML=parts.join('');
10364      svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10365      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10366      svg.onmousemove=function(e){{
10367        var rect=svg.getBoundingClientRect();
10368        var scaleX=W/rect.width;
10369        var mouseX=(e.clientX-rect.left)*scaleX;
10370        var nearest=-1,minDist=Infinity;
10371        for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
10372        if(nearest<0)return;
10373        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10374        var xhair=svg.querySelector('.mc-xhair');
10375        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10376        xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
10377        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10378        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10379        tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
10380        var bx=rect.left+(nc/W*rect.width)+18;
10381        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10382        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10383      }};
10384      svg.onmouseleave=function(){{
10385        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10386        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10387      }};
10388    }}
10389
10390    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10391
10392    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10393      btn.addEventListener('click',function(){{
10394        activeMetric=this.dataset.metric;
10395        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10396        this.classList.add('active');
10397        renderChart(activeMetric);
10398      }});
10399    }});
10400    if(typeof ResizeObserver!=='undefined'){{
10401      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10402    }}
10403    renderChart(activeMetric);
10404
10405    // ── File matrix table ────────────────────────────────────────────────────
10406    var activeStatus='';
10407    var currentPage=1;
10408    var perPage=25;
10409    var mcSortCol=null,mcSortAsc=true;
10410
10411    function getFiltered(){{
10412      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10413      if(!mcSortCol)return data;
10414      var asc=mcSortAsc;
10415      return data.slice().sort(function(a,b){{
10416        var va,vb;
10417        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10418        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10419        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10420        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10421        else{{return 0;}}
10422        if(asc)return va<vb?-1:va>vb?1:0;
10423        return va<vb?1:va>vb?-1:0;
10424      }});
10425    }}
10426
10427    function renderFilePage(){{
10428      var filtered=getFiltered();
10429      var total=filtered.length;
10430      var totalPages=Math.max(1,Math.ceil(total/perPage));
10431      if(currentPage>totalPages)currentPage=totalPages;
10432      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10433      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10434      var rows=[];
10435      for(var i=start;i<end;i++){{
10436        var f=filtered[i];
10437        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10438        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10439        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10440        for(var j=0;j<N;j++){{
10441          var cv=f.c[j];
10442          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10443          if(j<N-1){{
10444            var dv=f.d[j+1];
10445            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10446              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10447          }}
10448        }}
10449        var tc=f.t;
10450        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10451        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10452      }}
10453      tbody.innerHTML=rows.join('');
10454
10455      var info=document.getElementById('pg-info');
10456      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10457      renderPgBtns(totalPages);
10458    }}
10459
10460    function renderPgBtns(totalPages){{
10461      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10462      var btns=[];
10463      function mkBtn(label,page,active,disabled){{
10464        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10465        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10466      }}
10467      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10468      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10469      if(s>1)btns.push(mkBtn('1',1,false,false));
10470      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10471      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10472      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10473      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10474      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10475      wrap.innerHTML=btns.join('');
10476      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10477        b.addEventListener('click',function(){{
10478          var pg=parseInt(this.dataset.pg,10);
10479          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10480        }});
10481      }});
10482    }}
10483
10484    // Tab filter
10485    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10486      btn.addEventListener('click',function(){{
10487        activeStatus=this.dataset.status||'';
10488        currentPage=1;
10489        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10490        this.classList.add('active');
10491        renderFilePage();
10492      }});
10493    }});
10494
10495    // Per-page selector
10496    var ppSel=document.getElementById('per-page-sel');
10497    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10498
10499    // ── Column header sort ───────────────────────────────────────────────────
10500    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10501      th.addEventListener('click',function(){{
10502        var col=th.dataset.sortCol;
10503        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10504        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10505          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10506        }});
10507        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10508        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10509        currentPage=1;renderFilePage();
10510      }});
10511    }});
10512
10513    // Reset button also clears sort
10514    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10515    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10516      mcSortCol=null;mcSortAsc=true;
10517      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10518        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10519      }});
10520      activeStatus='';currentPage=1;
10521      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10522      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10523      renderFilePage();
10524    }});
10525
10526    renderFilePage();
10527
10528    // ── CSV export ───────────────────────────────────────────────────────────
10529    var exportBtn=document.getElementById('export-csv-btn');
10530    if(exportBtn)exportBtn.addEventListener('click',function(){{
10531      var header=['File','Language','Status'];
10532      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10533      header.push('Net Delta');
10534      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10535      var filtered=getFiltered();
10536      filtered.forEach(function(f){{
10537        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10538        for(var j=0;j<N;j++){{
10539          cols.push(f.c[j]!=null?f.c[j]:'');
10540          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10541        }}
10542        cols.push(f.t);
10543        rows.push(cols.join(','));
10544      }});
10545      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10546      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10547      a.download=mcExportName('csv');a.click();
10548    }});
10549
10550    // ── File matrix extra export buttons ─────────────────────────────────────
10551    (function(){{
10552      var resetBtn=document.getElementById('mc-file-reset-btn');
10553      if(resetBtn)resetBtn.addEventListener('click',function(){{
10554        activeStatus='';currentPage=1;
10555        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10556        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10557        renderFilePage();
10558      }});
10559
10560      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10561      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10562      function mcMakeXlsx(fname){{
10563        var filtered=getFiltered();
10564        var enc=new TextEncoder();
10565        var CT=[];for(var _n=0;_n<256;_n++){{var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}}
10566        function crc32(d){{var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}}
10567        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10568        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10569        var ss=[],si={{}};
10570        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10571        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10572        function WS(){{
10573          var R=0,buf=[];
10574          function cl(c){{return String.fromCharCode(65+c);}}
10575          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10576          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10577          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10578          function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
10579          return{{sc:sc,nc:nc,row:row,xml:xml}};
10580        }}
10581        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10582        var proj=mcExportProj();
10583        // \u2500\u2500 Summary sheet \u2500\u2500
10584        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10585        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10586        r1(s1(0,proj,2));
10587        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10588        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
10589        r1('');
10590        r1(s1(0,'SCAN SUMMARY',8));
10591        r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
10592        POINTS.forEach(function(p,i){{
10593          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10594          r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
10595        }});
10596        r1('');
10597        if(POINTS.length>1){{
10598          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10599          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10600          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10601          var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
10602          nr('Code Lines',pf.code,pl.code);
10603          nr('Comment Lines',pf.comments,pl.comments);
10604          nr('Files Analyzed',pf.files,pl.files);
10605          nr('Tests',pf.tests,pl.tests);
10606          r1('');
10607        }}
10608        var cMod=0,cAdd=0,cRem=0,cUnch=0;
10609        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10610        var totF=FILES.length||1;
10611        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10612        r1(s1(0,'FILE CHANGES',8));
10613        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10614        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10615        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10616        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10617        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10618        var lm={{}};
10619        FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
10620        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10621        if(langs.length){{
10622          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10623          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10624          langs.forEach(function(l){{var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));}});
10625        }}
10626        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10627        // \u2500\u2500 File Delta sheet \u2500\u2500
10628        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10629        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10630        for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
10631        hcells+=s2(hc,'Net Delta',3);
10632        r2(hcells);
10633        filtered.forEach(function(f){{
10634          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10635          for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
10636          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10637          r2(cells);
10638        }});
10639        var ncols=3+N+(N-1)+1;
10640        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10641        var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
10642        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10643        var F={{'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
10644          '_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
10645          'xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
10646          'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
10647          'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
10648          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10649        var zparts=[],zcds=[],zoff=0,znf=0;
10650        ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
10651          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10652          var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);
10653          var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);
10654          var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));
10655          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10656          zoff+=entry.length;znf++;
10657        }});
10658        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10659        var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
10660        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10661        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10662        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10663        out.set(new Uint8Array(eocd),pos);
10664        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10665        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10666      }}
10667
10668      var xlsBtn=document.getElementById('mc-file-xls-btn');
10669      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10670
10671      // File matrix HTML export — interactive: sort by column, filter by status
10672      function mcFileBuildHtml(){{
10673        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10674        var hdrs=['File','Language','Status'];
10675        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10676        hdrs.push('Net \u0394');
10677        var SI=2;
10678        var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
10679        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10680        var cnt={{all:allRows.length}};
10681        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10682        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10683        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10684          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10685          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10686          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10687          '.sub{{font-size:12px;color:#99aabb;}}'+
10688          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10689          '.wr{{padding:16px 20px;}}'+
10690          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10691          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10692          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10693          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10694          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10695          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10696          'thead tr{{background:#1a2035;}}'+
10697          'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
10698          'th:hover{{background:#2a3050;}}'+
10699          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10700          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10701          'tr:nth-child(even) td{{background:#faf7f4;}}'+
10702          'tr:hover td{{background:#f5f0ea;}}'+
10703          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10704          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10705        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10706        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10707          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10708          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10709          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10710          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10711        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10712          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
10713          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
10714          'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
10715          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10716          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10717          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
10718          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10719          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10720          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10721          'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
10722          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10723          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10724          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10725          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10726          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10727          'sd=(sc===ci)?-sd:1;sc=ci;'+
10728          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10729          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10730          'render();';
10731        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10732          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10733          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
10734          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10735          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10736          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10737          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10738          '<script>'+inlineJs+'<\/script><\/body><\/html>';
10739      }}
10740
10741      var htmlBtn=document.getElementById('mc-file-html-btn');
10742      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10743        var h=mcFileBuildHtml();
10744        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10745        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10746        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10747      }});
10748
10749      var pdfBtn=document.getElementById('mc-file-pdf-btn');
10750      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10751        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10752      }});
10753    }})();
10754
10755    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10756    (function(){{
10757      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10758      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10759      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10760      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10761      function fmt2(n){{return Number(n).toLocaleString();}}
10762      function px(n){{return Math.round(n);}}
10763      var _tt=document.getElementById('mc-ic-tt');
10764      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10765      function addTT(el){{
10766        if(!el)return;
10767        el.addEventListener('mouseover',function(e){{
10768          var t=e.target.closest('[data-ttl]');
10769          if(t&&_tt){{
10770            var ttl=t.getAttribute('data-ttl');
10771            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10772            _tt.style.display='block';mvTT(e);
10773            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10774            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10775          }} else {{
10776            if(_tt)_tt.style.display='none';
10777            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10778          }}
10779        }});
10780        el.addEventListener('mouseleave',function(){{
10781          if(_tt)_tt.style.display='none';
10782          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10783        }});
10784        el.addEventListener('mousemove',function(e){{mvTT(e);}});
10785      }}
10786      function mvTT(e){{if(!_tt)return;var x=e.clientX+16,y=e.clientY-10,r=_tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_tt.style.left=x+'px';_tt.style.top=y+'px';}}
10787      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10788      function buildCharts(){{
10789        if(N<2)return;
10790        var cs=getComputedStyle(document.body);
10791        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10792        var textCol=cv('--text','#43342d');
10793        var mutedCol=cv('--muted','#7b675b');
10794        var gFill=cv('--muted-2','#a08777');
10795        var LGY=cv('--line','#e6d0bf');
10796        var axisCol=cv('--line-strong','#d8bfad');
10797        var surf2col=cv('--surface-2','#f4ede4');
10798        var surfCol=cv('--surface','#fff8f0');
10799        var p0=POINTS[0],pLast=POINTS[N-1];
10800        var dark=document.body.classList.contains('dark-theme');
10801        var FADE=dark?'#524238':'#e6d0bf';
10802        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10803        function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
10804      var c1mets=[
10805        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10806        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10807        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10808      ];
10809      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10810      // Code Metrics chart — grows to fill the height its grid row settled to (the
10811      // Language Code Delta sibling usually drives that), so it never sits short at
10812      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10813      function drawC1(){{
10814        var C1W=620,C1H=200;
10815        var c1host=document.getElementById('mc-ic-c1');
10816        var c1card=c1host?c1host.closest('.ic-card'):null;
10817        if(c1host&&c1card&&c1host.clientWidth>0){{
10818          var avW=c1host.clientWidth;
10819          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10820          var wantH=availPx*C1W/avW;
10821          if(wantH>C1H)C1H=wantH;
10822        }}
10823        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10824        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10825        for(var gi=1;gi<=4;gi++){{
10826          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10827          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10828          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10829        }}
10830        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10831        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10832        c1mets.forEach(function(m,i){{
10833          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10834          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10835          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10836          c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
10837          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
10838          c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
10839          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
10840          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
10841          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
10842        }});
10843        c1+='</svg>';
10844        return c1;
10845      }}
10846      // Chart 2: Delta by Metric (net delta first scan to last)
10847      var mets=[
10848        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10849        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10850        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10851      ];
10852      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10853      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
10854      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10855      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10856      mets.forEach(function(m,i){{
10857        var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
10858        c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
10859        c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
10860        if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10861        else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10862      }});
10863      c2+='</svg>';
10864      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10865      var lm={{}};
10866      FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
10867      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10868      function drawC3(){{
10869        if(!langs.length)return'';
10870        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10871        var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
10872        var c3host=document.getElementById('mc-ic-c3');
10873        var c3card=document.getElementById('mc-ic-lang-card');
10874        var C3H=langs.length*30+24;
10875        if(c3host&&c3card&&c3host.clientWidth>0){{
10876          var avW=c3host.clientWidth;
10877          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
10878          var wantH=availPx*C3W/avW;
10879          if(wantH>C3H)C3H=wantH;
10880        }}
10881        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
10882        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10883        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10884        langs.forEach(function(l,i){{
10885          var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
10886          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
10887          c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
10888          if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10889          else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10890          c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
10891        }});
10892        c3+='</svg>';
10893        return c3;
10894      }}
10895      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
10896      var fm=0,fa=0,fr=0,fu=0;
10897      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
10898      var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
10899      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
10900      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
10901      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
10902      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
10903      if(segs.length===1){{
10904        c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10905        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
10906        c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
10907      }} else {{
10908        segs.forEach(function(s){{
10909          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
10910          var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
10911          var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
10912          c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10913          if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
10914          ang4+=sw;
10915        }});
10916      }}
10917      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
10918      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
10919      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
10920      segs.forEach(function(s,i){{
10921        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
10922        c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
10923        c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
10924        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
10925      }});
10926      c4+='</svg>';
10927      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
10928      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
10929      // once at natural height to seed the row, then both are filled to the row the
10930      // grid settled to, so neither sits short at the top of an over-tall cell.
10931      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
10932      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
10933      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
10934      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
10935      var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
10936      if(e1)e1.innerHTML=drawC1();
10937      }}
10938      buildCharts();
10939      renderInlineCharts=buildCharts;
10940      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
10941      (function(){{
10942        var ov=document.getElementById('ic-svg-modal-ov');
10943        var body=document.getElementById('ic-svg-modal-body');
10944        var ttl=document.getElementById('ic-svg-modal-title');
10945        var closeBtn=document.getElementById('ic-svg-modal-close');
10946        if(!ov||!body)return;
10947        function close(){{ov.classList.remove('open');body.innerHTML='';}}
10948        function open(srcId,title){{
10949          var src=document.getElementById(srcId);if(!src)return;
10950          ttl.textContent=title||'';
10951          var card=src.closest('.ic-card');
10952          var legHtml='';
10953          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
10954          body.innerHTML=legHtml+src.innerHTML;
10955          var svg=body.querySelector('svg');
10956          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
10957          addTT(body);
10958          ov.classList.add('open');
10959        }}
10960        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
10961          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
10962        }});
10963        if(closeBtn)closeBtn.addEventListener('click',close);
10964        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
10965        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
10966      }})();
10967
10968      // HTML legend hover → highlight matching SVG bars within the SAME card only
10969      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
10970        var metric=leg.getAttribute('data-highlight');
10971        var parentCard=leg.closest('.ic-card');
10972        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
10973        if(!chartEl)return;
10974        leg.addEventListener('mouseenter',function(){{
10975          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
10976            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
10977              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
10978              x.style.opacity='1';
10979            }} else {{
10980              x.style.opacity='0.28';
10981            }}
10982          }});
10983        }});
10984        leg.addEventListener('mouseleave',function(){{
10985          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10986        }});
10987      }});
10988      // Author handles
10989      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
10990
10991      // ── Export helpers ────────────────────────────────────────────────────────
10992      // Fetch one image from the server and return a data-URI Promise
10993      function mcFetchUri(path){{
10994        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
10995          return new Promise(function(res){{
10996            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
10997          }});
10998        }}).catch(function(){{return '';}});
10999      }}
11000      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11001      function mcInlineImgs(html,cb){{
11002        var paths=[],seen={{}};
11003        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11004        if(!paths.length){{cb(html);return;}}
11005        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11006          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11007          .catch(function(){{cb(html);}});
11008      }}
11009      // Capture full-page HTML with all table rows visible
11010      function mcRawHtml(pdfMode){{
11011        if(pdfMode)document.body.classList.add('pdf-mode');
11012        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11013        var html=document.documentElement.outerHTML;
11014        perPage=s;currentPage=p;renderFilePage();
11015        if(pdfMode)document.body.classList.remove('pdf-mode');
11016        return html;
11017      }}
11018
11019      // HTML export (full page with inlined images)
11020      function mcDoHtml(btn,fname){{
11021        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11022        mcInlineImgs(mcRawHtml(false),function(html){{
11023          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11024          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11025          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11026          btn.disabled=false;btn.innerHTML=orig;
11027        }});
11028      }}
11029      // PDF export — comprehensive document-style report: full numbers, all sections
11030      function mcBuildPdfHtml(){{
11031        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11032        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11033        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11034        function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11035        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11036        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11037        function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
11038        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11039        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11040        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11041        // Header/footer flow in document order (NOT position:fixed) — a fixed
11042        // header repeats every printed page in Chromium and overlaps the content
11043        // below it, swallowing the first rows of pages 2+ and clipping the cards
11044        // on page 1. The table <thead> repeats per page natively, so every row
11045        // stays visible.
11046        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11047          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11048          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11049          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11050          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11051          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11052          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11053          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11054          '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11055          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11056          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11057          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11058          '.body{{padding:12px 18px 0;}}'+
11059          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11060          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11061          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11062          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11063          '.sec{{margin-bottom:10px;}}'+
11064          '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11065          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11066          'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11067          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11068          'tr:nth-child(even) td{{background:#faf8f6;}}';
11069        // ── Metric Progression ────────────────────────────────────────────────
11070        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11071        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11072        var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11073        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11074        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11075        var progRows=POINTS.map(function(pt,i){{
11076          var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
11077          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11078            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11079            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11080            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11081            '<td style="text-align:right">'+full(pt.files)+'</td>';
11082          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11083          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11084          return r+'</tr>';
11085        }}).join('');
11086        // ── Scan-to-scan changes ──────────────────────────────────────────────
11087        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11088          var prev=POINTS[i];
11089          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11090          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11091          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11092            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11093            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11094            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11095            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11096        }}).join(''):'';
11097        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11098        var fmSection='';
11099        if(FILES&&FILES.length){{
11100          // Hard cap on per-scan columns so the table never overflows the page width.
11101          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11102          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11103          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11104          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11105          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11106          var fmRows=topFiles.map(function(f){{
11107            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11108            var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'&mdash;')+'</td>';
11109            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11110            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11111            return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11112          }}).join('');
11113          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11114          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11115            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11116        }}
11117        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11118          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11119          '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11120
11121          '<div class="body">'+
11122          '<div class="sg">'+
11123          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11124            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11125          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11126            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11127          (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11128            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11129          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11130          '</div>'+
11131          '<div class="sec"><p class="sh">Metric Progression</p>'+
11132          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11133          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11134          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11135          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11136          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11137          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11138          fmSection+
11139          '</div>'+
11140          '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} &middot; {n} scans</span></div></div>'+
11141          '</body></html>';
11142      }}
11143      function mcDoPdf(btn){{
11144        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11145      }}
11146
11147      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11148      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11149      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11150      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11151      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11152      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11153      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11154      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11155      if(location.protocol==='file:'){{
11156        [mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
11157        [mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
11158      }}
11159    }})();
11160    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11161    (function(){{
11162      function $(id){{return document.getElementById(id);}}
11163      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11164      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11165      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11166      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11167      function openModal(idx){{
11168        var ov=$('mc-modal-overlay');if(!ov)return;
11169        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11170        if(idx<0||idx>=N)return;
11171        var pt=POINTS[idx];
11172        titleEl.textContent='Scan '+(idx+1);
11173        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11174        subEl.textContent=lbl;
11175        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11176          '<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
11177          '<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
11178          '<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
11179          '<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
11180          (pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
11181          (pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
11182          '</div></div>';
11183        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11184          (pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
11185          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11186          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11187          (pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
11188          (pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
11189          (pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
11190          (pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
11191          '<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
11192          '</div>';
11193        var dHtml='';
11194        if(idx>0){{
11195          var prev=POINTS[idx-1];
11196          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11197          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11198            '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11199            '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11200            '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11201            '</div></div>';
11202        }}
11203        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11204        ov.classList.add('open');document.body.style.overflow='hidden';
11205      }}
11206      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11207      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11208      document.addEventListener('click',function(e){{
11209        if(!e.target||!e.target.closest)return;
11210        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11211        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11212        var card=e.target.closest('.mc-card');
11213        if(!card)return;
11214        if(e.target.closest('a'))return;
11215        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11216        var i=cards.indexOf(card);
11217        if(i>=0)openModal(i);
11218      }});
11219      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11220      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11221      var statTip=null;
11222      document.addEventListener('mousemove',function(e){{
11223        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11224        if(!box){{if(statTip)statTip.style.display='none';return;}}
11225        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11226        var tip=box.getAttribute('data-tip')||'';
11227        if(statTip.textContent!==tip)statTip.textContent=tip;
11228        statTip.style.display='block';
11229        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11230        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11231        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11232        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11233      }});
11234      (function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
11235    }})();
11236  }})();
11237  </script>
11238  <script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
11239  if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
11240  if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
11241  <!-- Scan card detail modal -->
11242  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11243    <div class="mc-modal" id="mc-modal">
11244      <div class="mc-modal-head">
11245        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11246        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11247      </div>
11248      <div class="mc-modal-body" id="mc-modal-body"></div>
11249    </div>
11250  </div>
11251  {toast_assets}
11252</body>
11253</html>"#,
11254        project_label = html_escape(project_label),
11255        n = n,
11256        scan_strip = scan_strip,
11257        mc_strip_class = mc_strip_class,
11258        metrics_thead = metrics_thead,
11259        metrics_tbody = metrics_tbody,
11260        file_col_headers = file_col_headers,
11261        total_files = total_files,
11262        files_modified = files_modified,
11263        files_added = files_added,
11264        files_removed = files_removed,
11265        files_unchanged = files_unchanged,
11266        points_json = points_json,
11267        file_matrix_json = file_matrix_json,
11268        nav_compare_active = nav_compare_active,
11269        version = version,
11270        csp_nonce = csp_nonce,
11271        scope_bar_html = scope_bar_html,
11272        scope_label = scope_label,
11273        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11274    )
11275}
11276
11277// ── Trend report page ─────────────────────────────────────────────────────────
11278// Protected. Interactive time-series chart page that loads scan history via
11279// /api/metrics/history and renders a vanilla-SVG line chart.
11280//
11281// GET /trend-reports
11282
11283#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11284async fn trend_report_handler(
11285    State(state): State<AppState>,
11286    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11287) -> Response {
11288    auto_scan_watched_dirs(&state).await;
11289
11290    let watched_dirs_list: Vec<String> = {
11291        let wd = state.watched_dirs.lock().await;
11292        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11293    };
11294
11295    // Collect distinct project roots for the root selector dropdown.
11296    let roots: Vec<String> = {
11297        let reg = state.registry.lock().await;
11298        let mut seen = std::collections::BTreeSet::new();
11299        reg.entries
11300            .iter()
11301            .flat_map(|e| e.input_roots.iter().cloned())
11302            .filter(|r| seen.insert(r.clone()))
11303            .collect()
11304    };
11305
11306    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11307    let nonce = &csp_nonce;
11308    let version = env!("CARGO_PKG_VERSION");
11309    let toast_assets = sloc_toast_assets(nonce);
11310
11311    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11312    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11313    // of interactive controls — folder watching is managed by the host administrator.
11314    let watched_dirs_html: String = if state.server_mode {
11315        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
11316    } else {
11317        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11318            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11319                .to_string()
11320        } else {
11321            watched_dirs_list
11322                .iter()
11323                .fold(String::new(), |mut s, d| {
11324                    use std::fmt::Write as _;
11325                    let escaped =
11326                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11327                    write!(
11328                        s,
11329                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
11330                    ).expect("write to String is infallible");
11331                    s
11332                })
11333        };
11334        format!(
11335            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
11336        )
11337    };
11338
11339    let html = format!(
11340        r##"<!doctype html>
11341<html lang="en">
11342<head>
11343  <meta charset="utf-8" />
11344  <meta name="viewport" content="width=device-width, initial-scale=1" />
11345  <title>OxideSLOC | Trend Reports</title>
11346  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11347  <style nonce="{nonce}">
11348    :root {{
11349      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11350      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11351      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11352      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11353      --info-bg:#eef3ff; --info-text:#4467d8;
11354    }}
11355    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11356    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
11357    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11358    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11359    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
11360    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
11361    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
11362    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11363    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
11364    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11365    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
11366    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11367    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11368    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
11369    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
11370    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11371    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11372    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11373    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11374    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
11375    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
11376    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
11377    .settings-modal{{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
11378    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11379    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
11380    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
11381    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
11382    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
11383    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11384    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
11385    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
11386    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
11387    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
11388    .tz-select:focus{{border-color:var(--oxide);}}
11389    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11390    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11391    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11392    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11393    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11394    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11395    .trend-title-block{{flex:1;min-width:0;}}
11396    .controls-centered{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
11397    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11398    .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
11399    .chart-select:focus{{border-color:var(--accent);}}
11400    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11401    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11402    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
11403    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11404    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11405    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11406    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
11407    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11408    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11409    .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
11410    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11411    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11412    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11413    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11414    .tr-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;white-space:nowrap;}}
11415    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11416    .tr-chart-full-modal{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
11417    .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11418    .chart-hint-inline{{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);font-weight:600;white-space:nowrap;margin-top:8px;}}
11419    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11420    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11421    .chart-section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
11422    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11423    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;user-select:none;}}
11424    .data-table td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
11425    .data-table tr:last-child td{{border-bottom:none;}}
11426    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11427    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11428    .table-wrap{{width:100%;overflow-x:auto;}}
11429    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11430    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11431    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11432    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11433    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11434    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11435    .filter-input{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:text;min-width:180px;}}
11436    .filter-select{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}}
11437    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11438    .pagination-info{{font-size:13px;color:var(--muted);}}
11439    .pagination-btns{{display:flex;gap:6px;}}
11440    .pg-btn{{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}}
11441    .pg-btn:hover{{background:var(--line);}} .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}} .pg-btn:disabled{{opacity:.35;cursor:default;pointer-events:none;}}
11442    #scan-history-table col:nth-child(1){{width:155px;}}
11443    #scan-history-table col:nth-child(2){{width:240px;}}
11444    #scan-history-table col:nth-child(3){{width:82px;}}
11445    #scan-history-table col:nth-child(4){{width:82px;}}
11446    #scan-history-table col:nth-child(5){{width:90px;}}
11447    #scan-history-table col:nth-child(6){{width:90px;}}
11448    #scan-history-table col:nth-child(7){{width:88px;}}
11449    #scan-history-table col:nth-child(8){{width:150px;}}
11450    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11451    .tag-chip{{display:inline-flex;padding:2px 8px;border-radius:999px;background:var(--info-bg);color:var(--info-text);font-size:11px;font-weight:700;margin-right:4px;}}
11452    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
11453    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11454    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11455    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11456    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11457    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11458    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
11459    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11460    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11461    .watched-chip-rm:hover{{color:var(--oxide);}}
11462    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11463    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11464    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11465    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11466    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11467    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11468    a.run-link:hover{{text-decoration:underline;}}
11469    .run-id-chip{{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}}
11470    .git-chip{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}}
11471    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11472    .metric-num{{font-weight:700;color:var(--text);}}
11473    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11474    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}}
11475    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11476    .btn.primary:hover{{opacity:.9;}}
11477    .rpt-btn{{min-width:58px;justify-content:center;}}
11478    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11479    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11480    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11481    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11482    .submod-details summary::-webkit-details-marker{{display:none;}}
11483    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11484    .submod-view-btn{{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}}
11485    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11486    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11487    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11488    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
11489    .export-btn:hover{{background:var(--line);}}
11490    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11491    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11492    .site-footer a{{color:var(--muted);}}
11493    .loading-state{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;gap:14px;color:var(--muted);font-size:13px;font-weight:600;}}
11494    .loading-spinner{{width:30px;height:30px;border:3px solid var(--line);border-top-color:var(--oxide);border-radius:50%;animation:spin-load 0.75s linear infinite;}}
11495    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11496    /* Modal system (Retention Policy / Clean-up) */
11497    .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11498    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11499    .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11500    .tr-modal{{background:rgba(255,255,255,0.90);}}
11501    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11502    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11503    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11504    .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11505    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11506    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11507    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11508    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11509    .tr-modal-body{{padding:22px 30px;}}
11510    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11511    .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11512    .tr-btn:hover{{transform:translateY(-1px);}}
11513    .tr-btn:active{{transform:translateY(0);}}
11514    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11515    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11516    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11517    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11518    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11519    .tr-btn-secondary:hover{{background:var(--line);}}
11520    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11521    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11522  </style>
11523</head>
11524<body>
11525  <div class="background-watermarks" aria-hidden="true">
11526    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11527    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11532  </div>
11533  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11534  <div class="top-nav">
11535    <div class="top-nav-inner">
11536      <a class="brand" href="/">
11537        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11538        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11539      </a>
11540      <div class="nav-right">
11541        <a class="nav-pill" href="/">Home</a>
11542        <div class="nav-dropdown">
11543          <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11544          <div class="nav-dropdown-menu">
11545            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
11546          </div>
11547        </div>
11548        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11549        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11550        <div class="nav-dropdown">
11551          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11552          <div class="nav-dropdown-menu">
11553            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
11554          </div>
11555        </div>
11556        <div class="server-status-wrap" id="server-status-wrap">
11557          <div class="nav-pill server-online-pill" id="server-status-pill">
11558            <span class="status-dot" id="status-dot"></span>
11559            <span id="server-status-label">Server</span>
11560            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11561          </div>
11562          <div class="server-status-tip">
11563            OxideSLOC is running — accessible on your network.
11564            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11565          </div>
11566        </div>
11567        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11568          <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
11569        </button>
11570        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11571          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
11572          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
11573        </button>
11574      </div>
11575    </div>
11576  </div>
11577
11578  <div class="page">
11579    {watched_dirs_html}
11580    <div class="summary-strip" id="trend-stats"></div>
11581    <div class="panel">
11582      <div class="trend-header">
11583        <div class="trend-title-block">
11584          <h1>Trend Reports</h1>
11585          <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
11586          <span class="chart-hint-inline">
11587            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
11588            Click a dot or row to view its full report &nbsp;·&nbsp; <span class="dot" style="background:#C45C10;"></span>&thinsp;regular scan &nbsp;<span class="dot" style="background:#4472C4;"></span>&thinsp;tagged / release scan
11589          </span>
11590        </div>
11591        <div class="chart-actions">
11592          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11593            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11594            Retention Policy
11595          </button>
11596          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11597            <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
11598            Clean up old runs
11599          </button>
11600          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11601            <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
11602            Export Excel
11603          </button>
11604          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11605            <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
11606            Export PNG
11607          </button>
11608          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11609            <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
11610            Export PDF
11611          </button>
11612        </div>
11613      </div>
11614
11615      <div class="controls-centered">
11616        <label>Project Root:
11617          <select class="chart-select" id="root-sel">
11618            <option value="">All projects</option>
11619          </select>
11620        </label>
11621        <label>Y Metric:
11622          <select class="chart-select" id="y-sel">
11623            <option value="code_lines">Code Lines</option>
11624            <option value="comment_lines">Comment Lines</option>
11625            <option value="blank_lines">Blank Lines</option>
11626            <option value="physical_lines">Physical Lines</option>
11627            <option value="files_analyzed">Files Analyzed</option>
11628          </select>
11629        </label>
11630        <label>X Axis:
11631          <select class="chart-select" id="x-sel">
11632            <option value="time">By Time</option>
11633            <option value="commit" selected>By Commit</option>
11634            <option value="release">By Release</option>
11635            <option value="tag">Tagged Commits</option>
11636          </select>
11637        </label>
11638        <label id="submodule-label" style="display:none;">Submodule:
11639          <select class="chart-select" id="sub-sel">
11640            <option value="">All (project total)</option>
11641          </select>
11642        </label>
11643        <label>Chart Size:
11644          <select class="chart-select" id="scale-sel">
11645            <option value="0.75">Compact</option>
11646            <option value="1.2" selected>Normal</option>
11647            <option value="1.38">Large</option>
11648          </select>
11649        </label>
11650        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
11651      </div>
11652
11653      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
11654      <div id="data-table-wrap" style="overflow-x:auto;"></div>
11655    </div>
11656  </div>
11657
11658  <script nonce="{nonce}">
11659    (function() {{
11660      // Theme persistence
11661      var b = document.body;
11662      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11663      var tgl = document.getElementById('theme-toggle');
11664      if (tgl) tgl.addEventListener('click', function() {{
11665        var d = b.classList.toggle('dark-theme');
11666        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11667      }});
11668
11669      // Watermark randomizer
11670      (function() {{
11671        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11672        if (!wms.length) return;
11673        var placed = [];
11674        function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
11675        function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
11676        var half=Math.floor(wms.length/2);
11677        wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
11678      }})();
11679
11680      // Code particles
11681      (function() {{
11682        var container = document.getElementById('code-particles');
11683        if (!container) return;
11684        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','.rs .go .py','sloc_core','render_html','2,163 code'];
11685        for (var i = 0; i < 38; i++) {{
11686          (function(idx) {{
11687            var el = document.createElement('span');
11688            el.className = 'code-particle';
11689            el.textContent = snippets[idx % snippets.length];
11690            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11691            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11692            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11693            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
11694            container.appendChild(el);
11695          }})(i);
11696        }}
11697      }})();
11698
11699      // Watched folder picker
11700      (function() {{
11701        var btn = document.getElementById('add-watched-btn');
11702        if (!btn) return;
11703        btn.addEventListener('click', function() {{
11704          fetch('/pick-directory?kind=reports')
11705            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11706            .then(function(data) {{
11707              if (!data.cancelled && data.selected_path) {{
11708                var form = document.createElement('form');
11709                form.method = 'POST';
11710                form.action = '/watched-dirs/add';
11711                var ri = document.createElement('input');
11712                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11713                var fi = document.createElement('input');
11714                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11715                form.appendChild(ri); form.appendChild(fi);
11716                document.body.appendChild(form);
11717                form.submit();
11718              }}
11719            }})
11720            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11721        }});
11722      }})();
11723
11724      // Settings / color-scheme modal
11725      (function() {{
11726        var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
11727        function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
11728        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11729        var btn=document.getElementById('settings-btn');if(!btn)return;
11730        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11731        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
11732        document.body.appendChild(m);
11733        var g=document.getElementById('scheme-grid');
11734        if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
11735        var cl=document.getElementById('settings-close');
11736        window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};var tzSel=document.getElementById('tz-select');var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}if(tzSel){{tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}window.applyTz(storedTz);
11737        btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
11738        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11739        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11740      }})();
11741    }})();
11742
11743    var ROOTS = {roots_json};
11744    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11745    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11746    var allData = [];
11747
11748    // Populate root selector
11749    var rootSel = document.getElementById('root-sel');
11750    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11751
11752    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
11753    function fmtFull(n){{return Number(n).toLocaleString();}}
11754    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
11755
11756    // Tooltip
11757    var tt = document.createElement('div');
11758    tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
11759    document.body.appendChild(tt);
11760    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11761    function moveTT(e){{var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}}
11762    function hideTT(){{tt.style.display='none';}}
11763    window.addEventListener('blur',function(){{hideTT();}});
11764    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11765
11766    function statExact(compact, full){{
11767      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11768    }}
11769    function statVal(n){{
11770      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11771    }}
11772
11773    function updateStats(data){{
11774      var statsEl=document.getElementById('trend-stats');
11775      if(!statsEl)return;
11776      if(!data||!data.length){{statsEl.innerHTML='';return;}}
11777      var yKey=document.getElementById('y-sel').value;
11778      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11779      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11780      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11781      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11782      var absDelta=Math.abs(delta);
11783      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11784      var deltaExact=statExact(deltaCompact,deltaFull);
11785      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11786      statsEl.innerHTML=
11787        '<div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">'+data.length+'</div><div class="stat-chip-label">Total Scans</div></div>'+
11788        '<div class="stat-chip"><div class="stat-chip-tip">The most recent recorded value for the selected metric</div><div class="stat-chip-val">'+statVal(lastVal)+'</div><div class="stat-chip-label">Latest '+(Y_LABELS[yKey]||yKey)+'</div></div>'+
11789        '<div class="stat-chip"><div class="stat-chip-tip">Change in the selected metric from the earliest to the latest scan</div><div class="stat-chip-val '+cls+'">'+sign+deltaCompact+deltaExact+'</div><div class="stat-chip-label">Net Change</div></div>'+
11790        '<div class="stat-chip"><div class="stat-chip-tip">Number of distinct project roots tracked across all scans</div><div class="stat-chip-val">'+Object.keys(projs).length+'</div><div class="stat-chip-label">Projects</div></div>';
11791    }}
11792
11793    var subSel = document.getElementById('sub-sel');
11794    var subLabel = document.getElementById('submodule-label');
11795
11796    function populateSubmodules(root){{
11797      if(!subSel||!subLabel)return;
11798      while(subSel.options.length>1)subSel.remove(1);
11799      subSel.value='';
11800      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11801      fetch(url)
11802        .then(function(r){{return r.json();}})
11803        .then(function(subs){{
11804          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11805          subs.forEach(function(s){{
11806            var o=document.createElement('option');
11807            o.value=s.name;
11808            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11809            subSel.appendChild(o);
11810          }});
11811          subLabel.style.display='';
11812        }})
11813        .catch(function(){{subLabel.style.display='none';}});
11814    }}
11815
11816    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11817
11818    function loadAndRender(){{
11819      var root = rootSel.value;
11820      var sub = subSel ? subSel.value : '';
11821      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11822      document.getElementById('data-table-wrap').innerHTML='';
11823      var url = '/api/metrics/history?limit=100'
11824        + (root ? '&root='+encodeURIComponent(root) : '')
11825        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
11826      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11827        allData = data;
11828        render(data);
11829        updateStats(data);
11830      }}).catch(function(){{
11831        document.getElementById('chart-wrap').innerHTML='<div class="empty-state">Failed to load scan history. Make sure the server is running and has recorded at least one scan.</div>';
11832      }});
11833    }}
11834
11835    function render(data){{
11836      var yKey = document.getElementById('y-sel').value;
11837      var xMode = document.getElementById('x-sel').value;
11838
11839      // Filter for tag/release mode
11840      var pts = data;
11841      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11842
11843      // Sort oldest-first for the line chart
11844      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11845
11846      var wrap = document.getElementById('chart-wrap');
11847      if(!pts.length){{
11848        var emptyMsg = (xMode === 'tag')
11849          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11850          : 'No scan data found for the selected filters.';
11851        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11852        renderTable([]);
11853        return;
11854      }}
11855
11856      var scaleEl=document.getElementById('scale-sel');
11857      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
11858      renderTrendInto(wrap, pts, yKey, xMode, sc);
11859      renderTable(pts, yKey);
11860    }}
11861
11862    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
11863    // Shared by the inline chart and the Full View modal so both render identically.
11864    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
11865      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
11866      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
11867      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
11868      var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
11869      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
11870
11871      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11872
11873      var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
11874      svg+='<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C45C10" stop-opacity="0.18"/><stop offset="100%" stop-color="#C45C10" stop-opacity="0"/></linearGradient></defs>';
11875
11876      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
11877
11878      // Grid + Y axis ticks
11879      for(var ti=0;ti<=5;ti++){{
11880        var gy=PT+CH-Math.round(ti/5*CH);
11881        var gv=Math.round(ti/5*maxY);
11882        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
11883        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
11884      }}
11885
11886      // X axis labels (every N-th point to avoid crowding)
11887      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
11888      pts.forEach(function(d,i){{
11889        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11890        if(i%labelEvery===0||i===pts.length-1){{
11891          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):(xMode==='release'?(d.nearest_tag||d.tags&&d.tags[0]||d.timestamp.substring(0,10)):(d.tags&&d.tags[0]?d.tags[0]:d.timestamp.substring(0,10)));
11892          svg+='<text x="'+x+'" y="'+(PT+CH+fsS*2)+'" text-anchor="middle" transform="rotate(30,'+x+','+(PT+CH+fsS*2)+')" font-family="'+FONT+'" font-size="'+fsS+'" fill="#7b675b">'+esc(lbl)+'</text>';
11893        }}
11894      }});
11895
11896      // Axis label
11897      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
11898      svg+='<text x="'+(PL+CW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+xAxisLabel+'</text>';
11899      svg+='<text x="'+Math.round(14*sc)+'" y="'+(PT+CH/2)+'" text-anchor="middle" transform="rotate(-90,'+Math.round(14*sc)+','+(PT+CH/2)+')" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+(Y_LABELS[yKey]||yKey)+'</text>';
11900
11901      // Area fill + line path
11902      var pathD='';
11903      pts.forEach(function(d,i){{
11904        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11905        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11906        pathD+=(i===0?'M':'L')+x+','+y;
11907      }});
11908      if(pts.length>1){{
11909        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
11910        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
11911      }}
11912      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
11913
11914      // Data points (clickable) + permanent value labels
11915      var showLabels = pts.length <= 40;
11916      var labelEveryN = pts.length > 20 ? 2 : 1;
11917      pts.forEach(function(d,i){{
11918        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11919        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11920        var hasTags=d.tags&&d.tags.length>0;
11921        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
11922        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
11923        svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
11924        if(showLabels && i%labelEveryN===0){{
11925          var lx=x, ly=y-r-5;
11926          svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
11927        }}
11928      }});
11929
11930      svg+='</svg>';
11931      wrap.innerHTML=svg;
11932
11933      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
11934      function lineYAt(mx){{
11935        var n=pts.length;
11936        if(n===0)return PT+CH;
11937        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
11938        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
11939        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
11940        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
11941        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
11942        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
11943        return y0+t*(y1-y0);
11944      }}
11945
11946      // SVG-level mousemove: show the value tooltip only when the pointer is over the
11947      // gradient fill (inside the chart and at/below the line) — never in the empty
11948      // space above the line. Cursor follows the same rule.
11949      (function(){{
11950        var svgEl=wrap.querySelector('svg');
11951        if(!svgEl)return;
11952        svgEl.addEventListener('mousemove',function(e){{
11953          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
11954          var rect=svgEl.getBoundingClientRect();
11955          var scaleX=W/Math.max(rect.width,1);
11956          var scaleY=H/Math.max(rect.height,1);
11957          var mouseX=(e.clientX-rect.left)*scaleX;
11958          var mouseY=(e.clientY-rect.top)*scaleY;
11959          var ly=lineYAt(mouseX);
11960          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
11961          svgEl.style.cursor='pointer';
11962          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
11963          var d=pts[idx];
11964          var val=Number(d[yKey]);
11965          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
11966          showTT(e,
11967            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
11968            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
11969            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
11970          );
11971        }});
11972        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
11973      }})();
11974
11975      // Attach point tooltips
11976      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
11977        c.addEventListener('mouseover',function(e){{
11978          var d=pts[parseInt(this.dataset.idx)];
11979          var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
11980          var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
11981          showTT(e,
11982            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
11983            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
11984            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
11985            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
11986          );
11987          this.setAttribute('r','8');
11988        }});
11989        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
11990        c.addEventListener('mousemove',moveTT);
11991        c.addEventListener('click',function(){{
11992          var d=pts[parseInt(this.dataset.idx)];
11993          if(d.html_url) window.open(d.html_url,'_blank');
11994        }});
11995      }});
11996    }}
11997
11998    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
11999    var shProjFilter='', shBranchFilter='';
12000
12001    function fmtPST(isoStr){{
12002      if(!isoStr)return'';
12003      var d=new Date(isoStr);
12004      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12005      if(window.fmtTz){{var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}return window.fmtTz(d.getTime(),tz);}}
12006      function p(n){{return n<10?'0'+n:String(n);}}
12007      function nthWeekdaySun(year,month,n){{var count=0,day=1;while(true){{var t=new Date(Date.UTC(year,month,day));if(t.getUTCDay()===0&&++count===n)return t;day++;}}}}
12008      var yr=d.getUTCFullYear();
12009      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12010      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12011      var isDST=d>=dstStart&&d<dstEnd;
12012      var off=isDST?-7*3600*1000:-8*3600*1000;
12013      var lbl=isDST?'PDT':'PST';
12014      var loc=new Date(d.getTime()+off);
12015      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12016    }}
12017
12018    function getShRows(){{
12019      var proj=shProjFilter.toLowerCase().trim();
12020      var branch=shBranchFilter;
12021      return shData.filter(function(d){{
12022        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12023        if(branch&&(d.branch||'')!==branch)return false;
12024        return true;
12025      }});
12026    }}
12027
12028    function renderShPage(){{
12029      var filtered=getShRows();
12030      if(shSortCol){{
12031        filtered.sort(function(a,b){{
12032          var va,vb;
12033          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12034          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12035          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12036          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12037          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12038          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12039        }});
12040      }}
12041      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12042      shPage=Math.min(shPage,totalPages);
12043      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12044      var visible=filtered.slice(start,end);
12045      var tbody=document.getElementById('sh-tbody');
12046      if(!tbody)return;
12047      tbody.innerHTML=visible.map(function(d){{
12048        var tsHtml=esc(fmtPST(d.timestamp));
12049        var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">&#8212;</span>';
12050        var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12051        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12052        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12053        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12054        var reportCell='';
12055        if(d.html_url){{
12056          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12057          if(d.has_pdf){{var pdfUrl=d.html_url.replace(/\/html$/,'/pdf');reportCell+='<a class="btn primary rpt-btn" href="'+esc(pdfUrl)+'" target="_blank" rel="noopener">PDF</a>';}}
12058          reportCell+='</div>';
12059        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12060        if(d.submodule_links&&d.submodule_links.length){{
12061          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12062          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12063          reportCell+='</div></details>';
12064        }}
12065        return '<tr>'
12066          +'<td>'+tsHtml+'</td>'
12067          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12068          +'<td>'+runIdHtml+'</td>'
12069          +'<td>'+commitHtml+'</td>'
12070          +'<td>'+branchHtml+'</td>'
12071          +'<td>'+tags+'</td>'
12072          +'<td class="num">'+metricHtml+'</td>'
12073          +'<td class="report-cell">'+reportCell+'</td>'
12074          +'</tr>';
12075      }}).join('');
12076      var pgRange=document.getElementById('sh-pg-range');
12077      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12078      var pgInfo=document.getElementById('sh-pg-info');
12079      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12080      var pgBtns=document.getElementById('sh-pg-btns');
12081      if(pgBtns){{
12082        pgBtns.innerHTML='';
12083        function mkPgBtn(lbl,pg,active,disabled){{
12084          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12085          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12086          return b;
12087        }}
12088        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12089        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12090        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12091        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12092      }}
12093    }}
12094
12095    function wireTableBehavior(){{
12096      var pf=document.getElementById('sh-proj-filter');
12097      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12098      var bf=document.getElementById('sh-branch-filter');
12099      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12100      var rb=document.getElementById('sh-reset-btn');
12101      if(rb)rb.addEventListener('click',function(){{
12102        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12103        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12104        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12105        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12106        renderShPage();
12107      }});
12108      var pps=document.getElementById('sh-per-page');
12109      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12110      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12111      ths.forEach(function(th){{
12112        th.addEventListener('click',function(e){{
12113          if(e.target.classList.contains('col-resize-handle'))return;
12114          var col=th.dataset.col;
12115          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12116          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12117          th.classList.add('sort-'+shSortOrder);
12118          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12119          shPage=1;renderShPage();
12120        }});
12121      }});
12122      var table=document.getElementById('scan-history-table');
12123      if(!table)return;
12124      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12125      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12126      allThs.forEach(function(th,i){{
12127        var handle=th.querySelector('.col-resize-handle');
12128        if(!handle||!cols[i])return;
12129        var startX,startW;
12130        handle.addEventListener('mousedown',function(e){{
12131          e.stopPropagation();e.preventDefault();
12132          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12133          handle.classList.add('dragging');
12134          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12135          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12136          document.addEventListener('mousemove',onMove);
12137          document.addEventListener('mouseup',onUp);
12138        }});
12139      }});
12140    }}
12141
12142    function renderTable(pts, yKey){{
12143      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12144      var wrap=document.getElementById('data-table-wrap');
12145      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12146      var yLabel=Y_LABELS[yKey]||yKey||'';
12147      shData=pts.slice().reverse();
12148      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12149      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12150      var branches={{}};
12151      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12152      var branchOpts='<option value="">All branches</option>';
12153      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12154      wrap.innerHTML=
12155        '<div class="chart-section-header">SCAN HISTORY</div>'+
12156        '<div class="filter-row">'+
12157          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12158          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12159          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12160        '</div>'+
12161        '<div class="table-wrap">'+
12162        '<table id="scan-history-table" class="data-table">'+
12163        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12164        '<thead><tr id="sh-thead">'+
12165        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12166        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12167        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12168        '<th>Commit<div class="col-resize-handle"></div></th>'+
12169        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12170        '<th>Tags<div class="col-resize-handle"></div></th>'+
12171        '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12172        '<th>Report<div class="col-resize-handle"></div></th>'+
12173        '</tr></thead>'+
12174        '<tbody id="sh-tbody"></tbody>'+
12175        '</table>'+
12176        '</div>'+
12177        '<div class="pagination">'+
12178          '<span class="pagination-info" id="sh-pg-info"></span>'+
12179          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12180          '<div style="display:flex;align-items:center;gap:8px;">'+
12181            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12182            '<select class="filter-select" id="sh-per-page">'+
12183              '<option value="10">10 per page</option>'+
12184              '<option value="25" selected>25 per page</option>'+
12185              '<option value="50">50 per page</option>'+
12186              '<option value="100">100 per page</option>'+
12187            '</select>'+
12188            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12189          '</div>'+
12190        '</div>';
12191      wireTableBehavior();
12192      renderShPage();
12193    }}
12194
12195    function exportXLSX(){{
12196      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12197      var xbtn=document.getElementById('export-xlsx-btn');
12198      var xorig=xbtn?xbtn.innerHTML:'';
12199      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12200      var root=rootSel.value;
12201      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12202      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12203        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12204        buildAndDownloadXLSX(cm);
12205      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12206    }}
12207
12208    function buildAndDownloadXLSX(churnMap){{
12209      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12210      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12211      // (sorted is newest-first), so a given project/commit appears at most once.
12212      var seenPC={{}},dedup=[];
12213      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12214      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12215      var s1R=dedup.map(function(d){{
12216        var c=churnMap[d.run_id]||{{}};
12217        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0];
12218      }});
12219      var pm={{}};
12220      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12221      var s2H=['Project','Scan Count','First Scan','Latest Scan','Latest Code Lines','Latest Comment Lines','Latest Blank Lines','Latest Physical Lines','Latest Files','Min Code Lines','Max Code Lines','Avg Code Lines'];
12222      var s2R=Object.keys(pm).map(function(p){{
12223        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12224        var lat=sc[sc.length-1],fst=sc[0];
12225        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12226        var mn=Math.min.apply(null,codes),mx=Math.max.apply(null,codes),av=Math.round(codes.reduce(function(a,b){{return a+b;}},0)/codes.length);
12227        return[p,sc.length,fst.timestamp.substring(0,16).replace('T',' '),lat.timestamp.substring(0,16).replace('T',' '),+(lat.code_lines)||0,+(lat.comment_lines)||0,+(lat.blank_lines)||0,+(lat.physical_lines)||0,+(lat.files_analyzed)||0,mn,mx,av];
12228      }});
12229      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12230      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12231      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12232      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12233    }}
12234
12235    function buildXLSX(sheets,chartRows,chartRows2){{
12236      function s2b(s){{return new TextEncoder().encode(s);}}
12237      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12238      function col2l(n){{var s='';while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}}return s;}}
12239      function crc32(d){{
12240        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
12241        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12242      }}
12243      function buildSheet(hdr,rows,drawRid,withCtrl){{
12244        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12245        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12246        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12247        x+='<row r="1">';
12248        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12249        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12250        x+='</row>';
12251        rows.forEach(function(row,ri){{
12252          var rn=ri+2;
12253          x+='<row r="'+rn+'">';
12254          row.forEach(function(cell,ci){{
12255            var addr=col2l(ci+1)+rn;
12256            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12257            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12258          }});
12259          if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12260          x+='</row>';
12261        }});
12262        x+='</sheetData>';
12263        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12264        return x+'</worksheet>';
12265      }}
12266      function buildChartXML(rows){{
12267        var sn="'Scan History'";
12268        var nr=rows.length,er=nr+1;
12269        var sd=[{{name:'Code Lines',col:'F',di:5,clr:'C45C10'}},{{name:'Comment Lines',col:'G',di:6,clr:'4472C4'}},{{name:'Blank Lines',col:'H',di:7,clr:'70AD47'}},{{name:'Physical Lines',col:'I',di:8,clr:'7030A0'}}];
12270        var catCol='C',catIdx=2;
12271        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12272        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12273        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12274        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12275        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12276        sd.forEach(function(s,i){{
12277          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12278          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12279          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12280          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12281          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12282          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12283          x+='</c:strCache></c:strRef></c:cat>';
12284          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12285          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12286          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12287        }});
12288        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12289        x+='<c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="2"/></c:catAx>';
12290        x+='<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="1"/><c:crossBetween val="between"/></c:valAx>';
12291        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12292        return x;
12293      }}
12294      function buildChartXML2(rows){{
12295        var sn="'By Project'";
12296        var nr=rows.length,er=nr+1;
12297        var sd=[{{name:'Latest Code Lines',col:'E',di:4,clr:'C45C10'}},{{name:'Latest Comment Lines',col:'F',di:5,clr:'4472C4'}},{{name:'Latest Blank Lines',col:'G',di:6,clr:'70AD47'}},{{name:'Latest Physical Lines',col:'H',di:7,clr:'7030A0'}}];
12298        var catCol='A',catIdx=0;
12299        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12300        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12301        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12302        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12303        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12304        sd.forEach(function(s,i){{
12305          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12306          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12307          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12308          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12309          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12310          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12311          x+='</c:strCache></c:strRef></c:cat>';
12312          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12313          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12314          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12315        }});
12316        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12317        x+='<c:catAx><c:axId val="3"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="4"/></c:catAx>';
12318        x+='<c:valAx><c:axId val="4"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="3"/><c:crossBetween val="between"/></c:valAx>';
12319        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12320        return x;
12321      }}
12322      function buildChartXML3(rows){{
12323        var sn="'Scan History'";
12324        var nr=rows.length,er=nr+1;
12325        var catCol='C',catIdx=2;
12326        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12327        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12328        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12329        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12330        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12331        x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12332        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12333        x+='<c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr></c:marker>';
12334        x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="t"/></c:dLbls>';
12335        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12336        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12337        x+='</c:strCache></c:strRef></c:cat>';
12338        x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12339        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12340        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12341        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12342        x+='<c:catAx><c:axId val="5"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="6"/></c:catAx>';
12343        x+='<c:valAx><c:axId val="6"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="5"/><c:crossBetween val="between"/></c:valAx>';
12344        x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12345        return x;
12346      }}
12347      function buildFocusSheet(drawRid){{
12348        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12349        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12350        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12351        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12352        x+='<sheetData><row r="1">';
12353        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12354        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12355        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12356        x+='</row></sheetData>';
12357        x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified"</formula1></dataValidation></dataValidations>';
12358        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12359        return x+'</worksheet>';
12360      }}
12361      var hasChart=!!(chartRows&&chartRows.length);
12362      var nr=hasChart?chartRows.length:0;
12363      var hasChart2=!!(chartRows2&&chartRows2.length);
12364      var nr2=hasChart2?chartRows2.length:0;
12365      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
12366      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>';
12367      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12368      if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12369      if(hasChart2){{ct+='<Override PartName="/xl/charts/chart2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12370      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12371      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
12372      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12373      sheets.forEach(function(s,i){{wbr+='<Relationship Id="rId'+(i+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}});
12374      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12375      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';
12376      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12377      wbx+='</sheets></workbook>';
12378      var files=[
12379        {{name:'[Content_Types].xml',data:s2b(ct)}},
12380        {{name:'_rels/.rels',data:s2b(dotrels)}},
12381        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12382        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12383        {{name:'xl/styles.xml',data:s2b(styl)}}
12384      ];
12385      // Chart embedded directly in Scan History (sheet1); By Project is plain
12386      sheets.forEach(function(s,i){{
12387        var sx;
12388        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12389        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12390        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12391      }});
12392      if(hasChart){{
12393        var fromRow=nr+4,toRow=nr+34;
12394        files.push({{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>')}});
12395        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12396        drx+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12397        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12398        drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12399        drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12400        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12401        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12402        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12403        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12404        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12405        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12406        files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12407        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12408        files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12409        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12410        drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12411        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12412        drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12413        drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12414        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12415        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12416        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12417        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12418        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12419        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12420        files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12421        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12422      }}
12423      if(hasChart2){{
12424        var fromRow2=nr2+4,toRow2=nr2+36;
12425        files.push({{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing2.xml"/></Relationships>')}});
12426        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12427        drx2+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12428        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12429        drx2+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12430        drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12431        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12432        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12433        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12434        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12435        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12436        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12437        files.push({{name:'xl/drawings/_rels/drawing2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart2.xml"/></Relationships>')}});
12438        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12439      }}
12440      var parts=[],offsets=[],total=0;
12441      files.forEach(function(f){{
12442        offsets.push(total);
12443        var nb=s2b(f.name),crc=crc32(f.data);
12444        var h=new DataView(new ArrayBuffer(30+nb.length));
12445        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12446        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12447        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12448        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12449        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12450        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12451        total+=30+nb.length+f.data.length;
12452      }});
12453      var cdStart=total;
12454      files.forEach(function(f,fi){{
12455        var nb=s2b(f.name),crc=crc32(f.data);
12456        var cd=new DataView(new ArrayBuffer(46+nb.length));
12457        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12458        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12459        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12460        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12461        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12462        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12463        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12464      }});
12465      var cdSz=total-cdStart;
12466      var eocd=new DataView(new ArrayBuffer(22));
12467      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12468      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12469      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12470      parts.push(new Uint8Array(eocd.buffer));
12471      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12472      var out=new Uint8Array(sz);var off=0;
12473      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12474      return out.buffer;
12475    }}
12476
12477    function trendTitleParts(){{
12478      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12479      var subSelEl=document.getElementById('sub-sel');
12480      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12481      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12482      var proj=(document.getElementById('root-sel').value)||'All projects';
12483      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12484      var cnt=(allData&&allData.length)||0;
12485      var now=new Date();
12486      function p2(n){{return(n<10?'0':'')+n;}}
12487      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12488      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12489    }}
12490
12491    function exportPNG(){{
12492      var svgEl=document.querySelector('#chart-wrap svg');
12493      if(!svgEl){{alert('No chart to export yet.');return;}}
12494      var svgStr=new XMLSerializer().serializeToString(svgEl);
12495      var vb=svgEl.viewBox.baseVal,scale=2;
12496      var headerH=84,footerH=36;
12497      var lw=(vb.width||900),lh=(vb.height||380);
12498      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12499      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12500      var url=URL.createObjectURL(blob);
12501      var img=new Image();
12502      var tp=trendTitleParts();
12503      img.onload=function(){{
12504        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12505        var ctx=canvas.getContext('2d');
12506        var cs=getComputedStyle(document.body);
12507        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12508        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12509        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12510        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12511        ctx.scale(scale,scale);
12512        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12513        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12514        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12515        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12516        ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12517        ctx.drawImage(img,0,headerH);
12518        var fy=headerH+lh;
12519        ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12520        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12521        ctx.fillText('\u00a9 2026 OxideSLOC  \u00b7  oxide-sloc v{version}  \u00b7  AGPL-3.0-or-later  \u00b7  github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12522        ctx.textAlign='left';
12523        URL.revokeObjectURL(url);
12524        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12525      }};
12526      img.src=url;
12527    }}
12528
12529    function exportPDF(){{
12530      var svgEl=document.querySelector('#chart-wrap svg');
12531      if(!svgEl){{alert('No chart to export yet.');return;}}
12532      var tp=trendTitleParts();
12533      var svgStr=new XMLSerializer().serializeToString(svgEl);
12534      var statsEl=document.getElementById('trend-stats');
12535      var statsHtml=statsEl?statsEl.innerHTML:'';
12536      var yK=document.getElementById('y-sel').value;
12537      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12538      var yL=yLabels[yK]||yK;
12539      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12540      var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
12541      rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
12542      tableHtml+='</tbody></table>';
12543      var css='<style>'
12544        +'*{{box-sizing:border-box;}}'
12545        +'html,body{{margin:0;padding:0;}}'
12546        // Masthead/footer flow in document order — a position:fixed header repeats
12547        // on every printed page in Chromium and hides the rows beneath it on pages
12548        // 2+. The trend table's <thead> repeats per page natively instead.
12549        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
12550        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12551        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12552        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12553        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12554        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12555        +'.rep-body{{padding:22px 34px 0;}}'
12556        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12557        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12558        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12559        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12560        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12561        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12562        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12563        +'.stat-chip-tip{{display:none!important;}}'
12564        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12565        +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12566        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12567        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12568        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12569        +'.rep-chart svg{{max-width:100%;height:auto;}}'
12570        +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
12571        +'.filter-row{{display:none!important;}}'
12572        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12573        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12574        +'th{{background:#f0e9e0;font-weight:800;}}'
12575        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12576        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12577        +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
12578        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12579        +'</style>';
12580      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12581        +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
12582        +'<div class="rep-body">'
12583        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12584        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12585        +'<div class="summary-strip">'+statsHtml+'</div>'
12586        +'<div class="rep-chart">'+svgStr+'</div>'
12587        +tableHtml
12588        +'</div>'
12589        +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
12590        +'</body></html>';
12591      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12592    }}
12593
12594    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12595      var el=document.getElementById(id);
12596      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12597    }});
12598    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12599    // tracks the container like the responsive Chart.js charts do.
12600    var _rsT=null;
12601    window.addEventListener('resize',function(){{
12602      if(_rsT)clearTimeout(_rsT);
12603      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12604    }});
12605    rootSel.addEventListener('change',function(){{
12606      populateSubmodules(rootSel.value);
12607      loadAndRender();
12608    }});
12609    if(subSel)subSel.addEventListener('change',loadAndRender);
12610
12611    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12612    (function(){{
12613      var fvBtn=document.getElementById('tr-chart-fv-btn');
12614      if(!fvBtn)return;
12615      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12616      fvBtn.addEventListener('click',function(){{
12617        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12618        var yKey=document.getElementById('y-sel').value;
12619        var xMode=document.getElementById('x-sel').value;
12620        var pts=allData;
12621        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12622        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12623        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12624        var tp=trendTitleParts();
12625        var ov=document.createElement('div');
12626        ov.className='tr-chart-full-modal';
12627        ov.innerHTML='<div class="tr-chart-full-inner">'
12628          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12629          +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
12630          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12631          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12632          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12633        document.body.appendChild(ov);
12634        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12635        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12636        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12637        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12638        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12639      }});
12640    }})();
12641
12642    var xlsxBtn=document.getElementById('export-xlsx-btn');
12643    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12644    var pngBtn=document.getElementById('export-png-btn');
12645    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12646    var pdfBtn=document.getElementById('export-pdf-btn');
12647    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12648
12649    // ── Clean-up modal ───────────────────────────────────────────────────────
12650    (function(){{
12651      var triggerBtn=document.getElementById('cleanup-runs-btn');
12652      if(!triggerBtn)return;
12653      var modal=document.createElement('div');
12654      modal.className='tr-modal-backdrop';
12655      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12656        +'<div class="tr-modal-head">'
12657        +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
12658        +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
12659        +'</div>'
12660        +'<div class="tr-modal-body">'
12661        +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
12662        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12663        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12664        +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
12665        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12666        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12667        +'</div>'
12668        +'<div class="tr-modal-foot">'
12669        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12670        +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
12671        +'</div></div>';
12672      document.body.appendChild(modal);
12673      triggerBtn.addEventListener('click',function(){{
12674        document.getElementById('cleanup-status').style.display='none';
12675        modal.style.display='flex';
12676      }});
12677      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12678      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12679      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12680        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12681        var confirmBtn=this;
12682        confirmBtn.disabled=true;
12683        var status=document.getElementById('cleanup-status');
12684        status.style.display='block';
12685        status.style.background='#dbeafe';status.style.color='#1e40af';
12686        status.textContent='Deleting\u2026';
12687        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12688        .then(function(resp){{
12689          return resp.json().then(function(d){{
12690            if(resp.ok){{
12691              status.style.background='#dcfce7';status.style.color='#166534';
12692              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12693              setTimeout(function(){{window.location.reload();}},1500);
12694            }}else{{
12695              status.style.background='#fee2e2';status.style.color='#991b1b';
12696              status.textContent='Error: '+(d.error||'Unexpected error');
12697              confirmBtn.disabled=false;
12698            }}
12699          }});
12700        }})
12701        .catch(function(e){{
12702          status.style.background='#fee2e2';status.style.color='#991b1b';
12703          status.textContent='Network error: '+String(e);
12704          confirmBtn.disabled=false;
12705        }});
12706      }});
12707    }})();
12708
12709    // ── Retention policy panel ────────────────────────────────────────────────
12710    (function(){{
12711      var triggerBtn=document.getElementById('retention-policy-btn');
12712      if(!triggerBtn)return;
12713      var modal=document.createElement('div');
12714      modal.className='tr-modal-backdrop';
12715      modal.style.zIndex='9001';
12716      modal.innerHTML=''
12717        +'<div class="tr-modal" style="max-width:640px;">'
12718        +'<div class="tr-modal-head">'
12719        +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
12720        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12721        +'</div>'
12722        +'<div class="tr-modal-body">'
12723        +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
12724        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12725        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12726        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12727        +'</div>'
12728        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12729        +'<div>'
12730        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
12731        +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
12732        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12733        +'</div>'
12734        +'<div>'
12735        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
12736        +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
12737        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12738        +'</div>'
12739        +'</div>'
12740        +'<div style="margin-bottom:20px;">'
12741        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
12742        +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
12743        +'<option value="1">Every hour</option>'
12744        +'<option value="6">Every 6 hours</option>'
12745        +'<option value="12">Every 12 hours</option>'
12746        +'<option value="24" selected>Every 24 hours</option>'
12747        +'<option value="48">Every 2 days</option>'
12748        +'<option value="72">Every 3 days</option>'
12749        +'<option value="168">Every week</option>'
12750        +'</select>'
12751        +'</div>'
12752        +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
12753        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12754        +'</div>'
12755        +'<div class="tr-modal-foot">'
12756        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12757        +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
12758        +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
12759        +'</div>'
12760        +'</div>';
12761      document.body.appendChild(modal);
12762
12763      function rpShowStatus(msg,ok){{
12764        var s=document.getElementById('rp-status');
12765        s.style.display='block';
12766        s.style.background=ok?'#dcfce7':'#fee2e2';
12767        s.style.color=ok?'#166534':'#991b1b';
12768        s.textContent=msg;
12769      }}
12770      function fmtAgo(iso){{
12771        if(!iso)return'Never';
12772        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12773        if(diff<60)return diff+'s ago';
12774        if(diff<3600)return Math.floor(diff/60)+'m ago';
12775        if(diff<86400)return Math.floor(diff/3600)+'h ago';
12776        return Math.floor(diff/86400)+'d ago';
12777      }}
12778      function loadPolicy(){{
12779        fetch('/api/cleanup-policy')
12780          .then(function(r){{return r.json();}})
12781          .then(function(d){{
12782            var p=d.policy;
12783            document.getElementById('rp-enabled').checked=p?p.enabled:false;
12784            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12785            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12786            var sel=document.getElementById('rp-interval');
12787            if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
12788            var lr=document.getElementById('rp-last-run');
12789            if(d.last_run_at){{
12790              lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
12791            }}else{{
12792              lr.textContent='Auto-cleanup has not run yet.';
12793            }}
12794          }})
12795          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12796      }}
12797
12798      triggerBtn.addEventListener('click',function(){{
12799        document.getElementById('rp-status').style.display='none';
12800        loadPolicy();
12801        modal.style.display='flex';
12802      }});
12803      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12804      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12805
12806      document.getElementById('rp-save-btn').addEventListener('click',function(){{
12807        var enabled=document.getElementById('rp-enabled').checked;
12808        var ageVal=document.getElementById('rp-max-age').value.trim();
12809        var countVal=document.getElementById('rp-max-count').value.trim();
12810        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12811        if(enabled&&!ageVal&&!countVal){{
12812          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12813          return;
12814        }}
12815        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12816        var saveBtn=document.getElementById('rp-save-btn');
12817        saveBtn.disabled=true;
12818        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12819          .then(function(r){{
12820            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12821            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12822          }})
12823          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12824          .finally(function(){{saveBtn.disabled=false;}});
12825      }});
12826
12827      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12828        var btn=this;
12829        var orig=btn.innerHTML;
12830        btn.disabled=true;
12831        btn.textContent='Running\u2026';
12832        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12833          .then(function(r){{return r.json();}})
12834          .then(function(d){{
12835            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12836            loadPolicy();
12837          }})
12838          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12839          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12840      }});
12841    }})();
12842
12843    populateSubmodules(rootSel.value);
12844    loadAndRender();
12845
12846    (function randomizeWatermarks() {{
12847      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12848      if (!wms.length) return;
12849      var placed = [];
12850      function tooClose(top, left) {{
12851        for (var i = 0; i < placed.length; i++) {{
12852          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12853          if (dt < 16 && dl < 12) return true;
12854        }}
12855        return false;
12856      }}
12857      function pick(leftBand) {{
12858        for (var attempt = 0; attempt < 50; attempt++) {{
12859          var top = Math.random() * 88 + 2;
12860          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12861          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
12862        }}
12863        var top = Math.random() * 88 + 2;
12864        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12865        placed.push([top, left]); return [top, left];
12866      }}
12867      var half = Math.floor(wms.length / 2);
12868      wms.forEach(function (img, i) {{
12869        var pos = pick(i < half);
12870        var size = Math.floor(Math.random() * 100 + 120);
12871        var rot = (Math.random() * 360).toFixed(1);
12872        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12873        img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
12874      }});
12875    }})();
12876    (function spawnCodeParticles() {{
12877      var container = document.getElementById('code-particles');
12878      if (!container) return;
12879      var snippets = [
12880        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12881        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12882        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12883        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12884        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
12885      ];
12886      var count = 38;
12887      for (var i = 0; i < count; i++) {{
12888        (function(idx) {{
12889          var el = document.createElement('span');
12890          el.className = 'code-particle';
12891          el.textContent = snippets[idx % snippets.length];
12892          var left = Math.random() * 94 + 2;
12893          var top = Math.random() * 88 + 6;
12894          var dur = (Math.random() * 10 + 9).toFixed(1);
12895          var delay = (Math.random() * 18).toFixed(1);
12896          var rot = (Math.random() * 26 - 13).toFixed(1);
12897          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12898          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
12899          container.appendChild(el);
12900        }})(i);
12901      }}
12902    }})();
12903  </script>
12904  <footer class="site-footer">
12905    local code analysis - metrics, history and reports
12906    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
12907    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12908    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12909    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12910    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12911  </footer>
12912  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
12913  {toast_assets}
12914</body>
12915</html>"##,
12916    );
12917
12918    Html(html).into_response()
12919}
12920
12921fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12922    use std::collections::HashMap;
12923    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
12924        return vec![];
12925    }
12926    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
12927    for rec in per_file_records {
12928        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
12929            let e = totals.entry(lang.display_name().to_string()).or_default();
12930            e.0 += u64::from(cov.lines_found);
12931            e.1 += u64::from(cov.lines_hit);
12932        }
12933    }
12934    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
12935    let mut pairs: Vec<(String, f64)> = totals
12936        .into_iter()
12937        .filter(|(_, (found, _))| *found > 0)
12938        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
12939        .collect();
12940    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
12941    pairs
12942        .iter()
12943        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
12944        .collect()
12945}
12946
12947fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
12948    let mut high = 0u64;
12949    let mut mid = 0u64;
12950    let mut low = 0u64;
12951    for rec in per_file_records {
12952        if let Some(cov) = &rec.coverage {
12953            if cov.lines_found == 0 {
12954                continue;
12955            }
12956            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
12957            if pct >= 80.0 {
12958                high += 1;
12959            } else if pct >= 50.0 {
12960                mid += 1;
12961            } else {
12962                low += 1;
12963            }
12964        }
12965    }
12966    (high, mid, low)
12967}
12968
12969fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12970    let mut arr: Vec<serde_json::Value> = per_file_records
12971        .iter()
12972        .filter_map(|rec| {
12973            rec.coverage.as_ref().map(|cov| {
12974                let line_pct = if cov.lines_found > 0 {
12975                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
12976                        / 10.0
12977                } else {
12978                    0.0
12979                };
12980                let fn_pct = if cov.functions_found > 0 {
12981                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
12982                        .round()
12983                        / 10.0
12984                } else {
12985                    -1.0
12986                };
12987                serde_json::json!({
12988                    "rel": rec.relative_path,
12989                    "lang": rec.language.map_or("?", |l| l.display_name()),
12990                    "line_pct": line_pct,
12991                    "fn_pct": fn_pct,
12992                    "lhit": cov.lines_hit,
12993                    "lfound": cov.lines_found,
12994                    "fhit": cov.functions_hit,
12995                    "ffound": cov.functions_found,
12996                })
12997            })
12998        })
12999        .collect();
13000    arr.sort_by(|a, b| {
13001        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13002        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13003        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13004    });
13005    arr
13006}
13007
13008#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13009fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13010    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13011        .totals_by_language
13012        .iter()
13013        .filter(|l| l.test_count > 0)
13014        .collect();
13015    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13016    let lang_tests: Vec<serde_json::Value> = langs
13017        .iter()
13018        .map(|l| {
13019            let d = if l.code_lines > 0 {
13020                l.test_count as f64 / l.code_lines as f64 * 1000.0
13021            } else {
13022                0.0
13023            };
13024            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13025                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13026                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13027        })
13028        .collect();
13029    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13030    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13031    let t = &run.summary_totals;
13032    let total_tests = t.test_count;
13033    let density = if t.code_lines > 0 {
13034        total_tests as f64 / t.code_lines as f64 * 1000.0
13035    } else {
13036        0.0
13037    };
13038    let most_tested = langs.first().map_or_else(
13039        || "\u{2014}".to_string(),
13040        |l| l.language.display_name().to_string(),
13041    );
13042    let test_files: u64 = run
13043        .per_file_records
13044        .iter()
13045        .filter(|f| f.raw_line_categories.test_count > 0)
13046        .count() as u64;
13047    let cov_line = if t.coverage_lines_found > 0 {
13048        format!(
13049            "{:.1}",
13050            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13051        )
13052    } else {
13053        "0".to_string()
13054    };
13055    let cov_fn = if t.coverage_functions_found > 0 {
13056        format!(
13057            "{:.1}",
13058            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13059        )
13060    } else {
13061        "0".to_string()
13062    };
13063    let cov_branch = if t.coverage_branches_found > 0 {
13064        format!(
13065            "{:.1}",
13066            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13067        )
13068    } else {
13069        "0".to_string()
13070    };
13071    let has_cov = !cov_arr.is_empty();
13072    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13073    serde_json::json!({
13074        "totals": {
13075            "test_count": total_tests,
13076            "assertions": t.test_assertion_count,
13077            "suites": t.test_suite_count,
13078            "test_files": test_files,
13079            "total_files": t.files_analyzed,
13080            "density_str": format!("{density:.1}"),
13081            "most_tested": most_tested,
13082            "langs_with_tests": langs.len(),
13083            "cov_line": cov_line,
13084            "cov_fn": cov_fn,
13085            "cov_branch": cov_branch,
13086        },
13087        "lang_tests": lang_tests,
13088        "cov": cov_arr,
13089        "cov_tiers": {"high": high, "mid": mid, "low": low},
13090        "file_cov": file_cov_arr,
13091        "has_coverage": has_cov,
13092        "submodules": {},
13093    })
13094}
13095
13096#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13097fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13098    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13099        .language_summaries
13100        .iter()
13101        .filter(|l| l.test_count > 0)
13102        .collect();
13103    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13104    let lang_tests: Vec<serde_json::Value> = langs
13105        .iter()
13106        .map(|l| {
13107            let d = if l.code_lines > 0 {
13108                l.test_count as f64 / l.code_lines as f64 * 1000.0
13109            } else {
13110                0.0
13111            };
13112            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13113                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13114                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13115        })
13116        .collect();
13117    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13118    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13119    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13120    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13121    let density = if sub.code_lines > 0 {
13122        total_tests as f64 / sub.code_lines as f64 * 1000.0
13123    } else {
13124        0.0
13125    };
13126    let most_tested = langs.first().map_or_else(
13127        || "\u{2014}".to_string(),
13128        |l| l.language.display_name().to_string(),
13129    );
13130    serde_json::json!({
13131        "totals": {
13132            "test_count": total_tests,
13133            "assertions": total_assertions,
13134            "suites": total_suites,
13135            "test_files": test_files_approx,
13136            "total_files": sub.files_analyzed,
13137            "density_str": format!("{density:.1}"),
13138            "most_tested": most_tested,
13139            "langs_with_tests": langs.len(),
13140            "cov_line": "0",
13141            "cov_fn": "0",
13142            "cov_branch": "0",
13143        },
13144        "lang_tests": lang_tests,
13145        "cov": [],
13146        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13147        "has_coverage": false,
13148    })
13149}
13150
13151fn compute_cov_json_str(run: &AnalysisRun) -> String {
13152    use std::collections::HashMap;
13153    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13154    for rec in &run.per_file_records {
13155        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13156            let e = totals.entry(lang.display_name().to_string()).or_default();
13157            e.0 += u64::from(cov.lines_found);
13158            e.1 += u64::from(cov.lines_hit);
13159        }
13160    }
13161    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13162    let mut pairs: Vec<(String, f64)> = totals
13163        .into_iter()
13164        .filter(|(_, (found, _))| *found > 0)
13165        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13166        .collect();
13167    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13168    let parts: Vec<String> = pairs
13169        .iter()
13170        .map(|(lang, pct)| {
13171            let name = lang.replace('"', "\\\"");
13172            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13173        })
13174        .collect();
13175    format!("[{}]", parts.join(","))
13176}
13177
13178fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13179    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13180    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13181}
13182
13183fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13184    let mut entry = build_test_scope_entry(run);
13185    if !run.submodule_summaries.is_empty() {
13186        let subs: serde_json::Map<String, serde_json::Value> = run
13187            .submodule_summaries
13188            .iter()
13189            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13190            .collect();
13191        entry["submodules"] = serde_json::Value::Object(subs);
13192    }
13193    entry
13194}
13195
13196fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13197    let name = l.language.display_name().replace('"', "\\\"");
13198    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13199    let density = if l.code_lines > 0 {
13200        l.test_count as f64 / l.code_lines as f64 * 1000.0
13201    } else {
13202        0.0
13203    };
13204    format!(
13205        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13206        name = name,
13207        t = l.test_count,
13208        a = l.test_assertion_count,
13209        s = l.test_suite_count,
13210        c = l.code_lines,
13211        d = density,
13212        f = l.files,
13213    )
13214}
13215
13216fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13217    let Some(r) = run else {
13218        return "[]".to_string();
13219    };
13220    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13221        .totals_by_language
13222        .iter()
13223        .filter(|l| l.test_count > 0)
13224        .collect();
13225    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13226    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13227    format!("[{}]", parts.join(","))
13228}
13229
13230/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13231async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13232    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13233    scope_map.insert(
13234        "__all__".to_string(),
13235        latest_run.map_or_else(
13236            || {
13237                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13238                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13239                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13240                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13241                    "has_coverage":false,"submodules":{}})
13242            },
13243            build_test_scope_entry,
13244        ),
13245    );
13246    let all_roots: Vec<String> = {
13247        let reg = state.registry.lock().await;
13248        let mut seen = std::collections::BTreeSet::new();
13249        reg.entries
13250            .iter()
13251            .flat_map(|e| e.input_roots.iter().cloned())
13252            .filter(|r| seen.insert(r.clone()))
13253            .collect()
13254    };
13255    for root in &all_roots {
13256        let json_path = {
13257            let reg = state.registry.lock().await;
13258            reg.entries
13259                .iter()
13260                .find(|e| e.input_roots.iter().any(|r| r == root))
13261                .and_then(|e| e.json_path.clone())
13262        };
13263        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13264            let json_str = tokio::fs::read_to_string(&p).await.ok();
13265            json_str
13266                .as_deref()
13267                .and_then(|s| serde_json::from_str(s).ok())
13268        } else {
13269            None
13270        };
13271        if let Some(ref run) = run_for_root {
13272            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13273        }
13274    }
13275    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13276}
13277
13278// GET /test-metrics
13279#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13280#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13281async fn test_metrics_handler(
13282    State(state): State<AppState>,
13283    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13284) -> Response {
13285    auto_scan_watched_dirs(&state).await;
13286    let watched_dirs_list: Vec<String> = {
13287        let wd = state.watched_dirs.lock().await;
13288        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13289    };
13290    let latest_run: Option<AnalysisRun> = {
13291        let json_path = {
13292            let reg = state.registry.lock().await;
13293            reg.entries.first().and_then(|e| e.json_path.clone())
13294        };
13295        if let Some(p) = json_path {
13296            let json_str = tokio::fs::read_to_string(&p).await.ok();
13297            json_str
13298                .as_deref()
13299                .and_then(|s| serde_json::from_str(s).ok())
13300        } else {
13301            None
13302        }
13303    };
13304
13305    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13306    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13307
13308    // Build coverage chart JSON (per-language avg line coverage %).
13309    let cov_json: String = latest_run
13310        .as_ref()
13311        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13312        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13313
13314    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13315    let _cov_tier_json: String = latest_run
13316        .as_ref()
13317        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13318        .map_or_else(
13319            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13320            compute_cov_tier_json_str,
13321        );
13322
13323    let total_tests: u64 = latest_run
13324        .as_ref()
13325        .map_or(0, |r| r.summary_totals.test_count);
13326    let total_assertions: u64 = latest_run
13327        .as_ref()
13328        .map_or(0, |r| r.summary_totals.test_assertion_count);
13329    let total_suites: u64 = latest_run
13330        .as_ref()
13331        .map_or(0, |r| r.summary_totals.test_suite_count);
13332    let total_code: u64 = latest_run
13333        .as_ref()
13334        .map_or(0, |r| r.summary_totals.code_lines);
13335    let workspace_density: f64 = if total_code > 0 {
13336        total_tests as f64 / total_code as f64 * 1000.0
13337    } else {
13338        0.0
13339    };
13340    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13341        r.totals_by_language
13342            .iter()
13343            .filter(|l| l.test_count > 0)
13344            .count()
13345    });
13346    let most_tested: String = latest_run
13347        .as_ref()
13348        .and_then(|r| {
13349            r.totals_by_language
13350                .iter()
13351                .filter(|l| l.test_count > 0)
13352                .max_by_key(|l| l.test_count)
13353        })
13354        .map_or_else(
13355            || "\u{2014}".to_string(),
13356            |l| l.language.display_name().to_string(),
13357        );
13358    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13359        r.per_file_records
13360            .iter()
13361            .filter(|f| f.raw_line_categories.test_count > 0)
13362            .count() as u64
13363    });
13364    let total_files_analyzed: u64 = latest_run
13365        .as_ref()
13366        .map_or(0, |r| r.summary_totals.files_analyzed);
13367    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13368
13369    // Aggregated coverage percentages from summary_totals
13370    let cov_line_pct_str: String = latest_run
13371        .as_ref()
13372        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13373        .map_or_else(
13374            || "0".to_string(),
13375            |r| {
13376                format!(
13377                    "{:.1}",
13378                    r.summary_totals.coverage_lines_hit as f64
13379                        / r.summary_totals.coverage_lines_found as f64
13380                        * 100.0
13381                )
13382            },
13383        );
13384    let cov_fn_pct_str: String = latest_run
13385        .as_ref()
13386        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13387        .map_or_else(
13388            || "0".to_string(),
13389            |r| {
13390                format!(
13391                    "{:.1}",
13392                    r.summary_totals.coverage_functions_hit as f64
13393                        / r.summary_totals.coverage_functions_found as f64
13394                        * 100.0
13395                )
13396            },
13397        );
13398    let cov_branch_pct_str: String = latest_run
13399        .as_ref()
13400        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13401        .map_or_else(
13402            || "0".to_string(),
13403            |r| {
13404                format!(
13405                    "{:.1}",
13406                    r.summary_totals.coverage_branches_hit as f64
13407                        / r.summary_totals.coverage_branches_found as f64
13408                        * 100.0
13409                )
13410            },
13411        );
13412
13413    let cov_no_data_notice = if has_coverage {
13414        String::new()
13415    } else {
13416        String::from(
13417            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13418<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
13419<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13420  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13421  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
13422  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13423  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
13424  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13425  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
13426  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13427  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13428  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13429  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13430</div>
13431<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13432</div>"#,
13433        )
13434    };
13435
13436    let workspace_density_str = format!("{workspace_density:.1}");
13437    let nonce = &csp_nonce;
13438    let toast_assets = sloc_toast_assets(nonce);
13439    let version = env!("CARGO_PKG_VERSION");
13440
13441    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13442    // of interactive controls — folder watching is managed by the host administrator.
13443    let watched_dirs_html: String = if state.server_mode {
13444        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13445    } else {
13446        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13447            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13448                .to_string()
13449        } else {
13450            watched_dirs_list
13451                .iter()
13452                .fold(String::new(), |mut s, d| {
13453                    use std::fmt::Write as _;
13454                    let escaped =
13455                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13456                    write!(
13457                        s,
13458                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
13459                    ).expect("write to String is infallible");
13460                    s
13461                })
13462        };
13463        format!(
13464            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
13465        )
13466    };
13467
13468    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13469    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13470
13471    let html = format!(
13472        r#"<!doctype html>
13473<html lang="en">
13474<head>
13475  <meta charset="utf-8" />
13476  <meta name="viewport" content="width=device-width, initial-scale=1" />
13477  <title>OxideSLOC | Test Metrics</title>
13478  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13479  <style nonce="{nonce}">
13480    :root {{
13481      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13482      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13483      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13484      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13485      --info-bg:#eef3ff; --info-text:#4467d8;
13486    }}
13487    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13488    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
13489    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13490    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13491    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
13492    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
13493    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
13494    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13495    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
13496    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13497    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13498    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13499    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13500    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
13501    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
13502    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13503    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13504    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13505    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13506    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
13507    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
13508    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13509    .settings-modal{{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
13510    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13511    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
13512    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
13513    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13514    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13515    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13516    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
13517    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13518    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13519    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
13520    .tz-select:focus{{border-color:var(--oxide);}}
13521    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13522    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13523    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13524    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13525    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13526    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13527    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13528    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
13529    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13530    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13531    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13532    .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
13533    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}}
13534    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13535    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13536    .section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
13537    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13538    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13539    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13540    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13541    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13542    .chart-canvas-wrap{{position:relative;height:280px;}}
13543    .chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
13544    .chart-no-data svg{{opacity:0.35;}}
13545    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13546    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13547    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13548    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}}
13549    .data-table td{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
13550    .data-table tr:last-child td{{border-bottom:none;}}
13551    .data-table tbody tr:hover td{{background:var(--surface-2);}}
13552    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13553    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13554    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13555    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13556    .cov-gauge-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
13557    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13558    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13559    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13560    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13561    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13562    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13563    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13564    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13565    .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
13566    .chart-select:focus{{border-color:var(--accent);}}
13567    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13568    .trend-canvas-wrap{{position:relative;height:260px;}}
13569    .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
13570    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13571    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13572    .site-footer a{{color:var(--muted);}}
13573    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13574    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .13s;}}
13575    .btn:hover{{background:var(--surface-2);}}
13576    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
13577    .export-btn:hover{{background:var(--line);}}
13578    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13579    .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
13580    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13581    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13582    .scope-sel{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;max-width:500px;}}
13583    .scope-sel:focus{{border-color:var(--accent);}}
13584    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13585    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
13586    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13587    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13588    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13589    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
13590    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13591    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13592    .watched-chip-rm:hover{{color:var(--oxide);}}
13593    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13594    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13595    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13596    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13597    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13598    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13599    .cov-tab{{padding:4px 12px;border-radius:20px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}}
13600    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13601    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13602    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13603    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13604    .cov-file-search{{flex:1;min-width:160px;max-width:340px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;outline:none;}}
13605    .cov-file-search:focus{{border-color:var(--accent);}}
13606    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13607    .cov-file-path{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13608    body.dark-theme .cov-file-search{{background:var(--surface);}}
13609    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13610    .chart-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
13611    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13612    .chart-modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
13613    .chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
13614    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13615    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13616    .chart-modal-close{{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}}
13617    .chart-modal-close:hover{{opacity:.7;}}
13618    body.dark-theme .chart-modal{{background:var(--surface);}}
13619  </style>
13620</head>
13621<body>
13622  <div class="background-watermarks" aria-hidden="true">
13623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13628    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13629  </div>
13630  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13631  <div class="top-nav">
13632    <div class="top-nav-inner">
13633      <a class="brand" href="/">
13634        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13635        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13636      </a>
13637      <div class="nav-right">
13638        <a class="nav-pill" href="/">Home</a>
13639        <div class="nav-dropdown">
13640          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
13641          <div class="nav-dropdown-menu">
13642            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
13643          </div>
13644        </div>
13645        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13646        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13647        <div class="nav-dropdown">
13648          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
13649          <div class="nav-dropdown-menu">
13650            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
13651          </div>
13652        </div>
13653        <div class="server-status-wrap" id="server-status-wrap">
13654          <div class="nav-pill server-online-pill" id="server-status-pill">
13655            <span class="status-dot" id="status-dot"></span>
13656            <span id="server-status-label">Server</span>
13657            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13658          </div>
13659          <div class="server-status-tip">
13660            OxideSLOC is running — accessible on your network.
13661            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13662          </div>
13663        </div>
13664        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13665          <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
13666        </button>
13667        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13668          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
13669          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
13670        </button>
13671      </div>
13672    </div>
13673  </div>
13674
13675  <div class="page">
13676    {watched_dirs_html}
13677    <div class="scope-bar">
13678      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
13679      <span class="scope-label">Scope</span>
13680      <div class="scope-sel-wrap">
13681        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13682        <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
13683          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
13684          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13685        </div>
13686      </div>
13687    </div>
13688    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13689      <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
13690      <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
13691      <div class="stat-chip"><div class="stat-chip-val" id="chip-suites">{total_suites}</div><div class="stat-chip-label">Test Suites</div><div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div></div>
13692      <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
13693    </div>
13694    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13695      <div class="stat-chip"><div class="stat-chip-val" id="chip-density">{workspace_density_str}</div><div class="stat-chip-label">Tests per 1K SLOC</div><div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div></div>
13696      <div class="stat-chip"><div class="stat-chip-val" id="chip-most">{most_tested}</div><div class="stat-chip-label">Most Tested Language</div><div class="stat-chip-tip">Language with the highest absolute test function count</div></div>
13697      <div class="stat-chip"><div class="stat-chip-val" id="chip-langs">{langs_with_tests}</div><div class="stat-chip-label">Languages with Tests</div><div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div></div>
13698      <div class="stat-chip"><div class="stat-chip-val" id="chip-cov-pct">{cov_line_pct_str}%</div><div class="stat-chip-label">Line Coverage</div><div class="stat-chip-tip">Overall line coverage across all LCOV-instrumented files (empty if no LCOV data)</div></div>
13699    </div>
13700
13701    <div class="panel" id="viz-panel">
13702      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13703        <span>Visualizations</span>
13704        <div style="display:flex;gap:8px;flex-wrap:wrap;">
13705          <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download test metrics as Excel workbook (.xlsx)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export Excel</button>
13706          <button type="button" class="export-btn" id="tm-export-png-btn" title="Save charts as PNG image"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Export PNG</button>
13707          <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export printable PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13708        </div>
13709      </div>
13710
13711      <div class="chart-box" style="margin-bottom:18px;">
13712        <div class="chart-box-header">
13713          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13714          <div style="display:flex;gap:8px;align-items:center;">
13715            <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">&#8652; Multi-Timeline</button>
13716            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13717          </div>
13718        </div>
13719        <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
13720        <div class="trend-controls-bar">
13721          <label>Y Metric:
13722            <select class="chart-select" id="tm-trend-y">
13723              <option value="test_count" selected>Test Definitions</option>
13724              <option value="code_lines">Code Lines</option>
13725            </select>
13726          </label>
13727          <label>X Axis:
13728            <select class="chart-select" id="tm-trend-x">
13729              <option value="commit" selected>By Commit</option>
13730              <option value="time">By Time</option>
13731            </select>
13732          </label>
13733          <label id="tm-sub-label" style="display:none;">Submodule:
13734            <select class="chart-select" id="tm-trend-sub">
13735              <option value="">All (project total)</option>
13736            </select>
13737          </label>
13738          <label>Chart Size:
13739            <select class="chart-select" id="tm-trend-size">
13740              <option value="200">Compact</option>
13741              <option value="260" selected>Normal</option>
13742              <option value="360">Large</option>
13743            </select>
13744          </label>
13745        </div>
13746        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13747        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13748      </div>
13749
13750      <div class="chart-row">
13751        <div class="chart-box">
13752          <div class="chart-box-header">
13753            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13754            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13755          </div>
13756          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13757          <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
13758        </div>
13759        <div class="chart-box">
13760          <div class="chart-box-header">
13761            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13762            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13763          </div>
13764          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13765          <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
13766        </div>
13767      </div>
13768
13769      <div class="chart-row">
13770        <div class="chart-box">
13771          <div class="chart-box-header">
13772            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13773            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13774          </div>
13775          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13776          <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
13777        </div>
13778        <div class="chart-box" id="suites-chart-box">
13779          <div class="chart-box-header">
13780            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13781            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13782          </div>
13783          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13784          <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
13785        </div>
13786      </div>
13787
13788      <div class="chart-row">
13789        <div class="chart-box">
13790          <div class="chart-box-title">Test Files Breakdown</div>
13791          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13792          <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
13793        </div>
13794        <div class="chart-box">
13795          <div class="chart-box-title">Test Composition</div>
13796          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13797          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13798          <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
13799        </div>
13800      </div>
13801    </div>
13802
13803    <div class="panel">
13804      <h1>Test Metrics</h1>
13805      <p class="muted">Lexical test definition counts across your codebase — how many test functions, test cases, and test decorators were detected per language, and how dense the test coverage is relative to production code.</p>
13806
13807      <div class="section-header">Language Breakdown</div>
13808      {cov_no_data_notice}
13809      <div style="overflow-x:auto;">
13810        <table class="data-table" id="lang-table">
13811          <thead><tr>
13812            <th>Language</th>
13813            <th class="num">Test Fns</th>
13814            <th class="num">Assertions</th>
13815            <th class="num">Suites</th>
13816            <th class="num">Code Lines</th>
13817            <th class="num">Files</th>
13818            <th class="num">Density / 1K</th>
13819            <th>Relative Density</th>
13820          </tr></thead>
13821          <tbody id="lang-tbody"></tbody>
13822        </table>
13823      </div>
13824    </div>
13825
13826    <div class="panel" id="cov-panel" style="display:none;">
13827      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
13828      <div class="cov-gauge-row" id="cov-gauges">
13829        <div class="cov-gauge-card">
13830          <div class="cov-gauge-label">Line Coverage</div>
13831          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
13832          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
13833          <div class="cov-gauge-sub">Lines hit / instrumented</div>
13834        </div>
13835        <div class="cov-gauge-card">
13836          <div class="cov-gauge-label">Function Coverage</div>
13837          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
13838          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
13839          <div class="cov-gauge-sub">Functions hit / found</div>
13840        </div>
13841        <div class="cov-gauge-card">
13842          <div class="cov-gauge-label">Branch Coverage</div>
13843          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
13844          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
13845          <div class="cov-gauge-sub">Branches hit / found</div>
13846        </div>
13847      </div>
13848      <div class="chart-row">
13849        <div class="chart-box">
13850          <div class="chart-box-title">Line Coverage % by Language</div>
13851          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
13852        </div>
13853        <div class="chart-box">
13854          <div class="chart-box-title">Coverage Tier Distribution</div>
13855          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
13856        </div>
13857      </div>
13858
13859      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
13860      <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
13861      <div class="cov-file-toolbar">
13862        <div class="cov-filter-tabs" id="cov-filter-tabs">
13863          <button class="cov-tab active" data-tier="all">All</button>
13864          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
13865          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
13866          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
13867          <button class="cov-tab" data-tier="high">High (≥80%)</button>
13868        </div>
13869        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename\u2026">
13870      </div>
13871      <div style="overflow-x:auto;">
13872        <table class="data-table" id="cov-file-table">
13873          <thead><tr>
13874            <th>File</th>
13875            <th>Lang</th>
13876            <th class="num">Line %</th>
13877            <th class="num">Lines Hit / Found</th>
13878            <th class="num">Fn %</th>
13879            <th class="num">Fns Hit / Found</th>
13880          </tr></thead>
13881          <tbody id="cov-file-tbody"></tbody>
13882        </table>
13883      </div>
13884      <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
13885      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
13886    </div>
13887
13888  </div>
13889
13890  <footer class="site-footer">
13891    local code analysis - metrics, history and reports
13892    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
13893    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13894    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13895    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13896    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13897  </footer>
13898
13899  <script nonce="{nonce}">
13900  (function() {{
13901    // Theme
13902    var b = document.body;
13903    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
13904    var tgl = document.getElementById('theme-toggle');
13905    if (tgl) tgl.addEventListener('click', function() {{
13906      var d = b.classList.toggle('dark-theme');
13907      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
13908    }});
13909
13910    // Watermarks
13911    (function() {{
13912      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13913      if (!wms.length) return;
13914      var placed = [];
13915      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
13916      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
13917      var half=Math.floor(wms.length/2);
13918      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
13919    }})();
13920
13921    // Code particles
13922    (function() {{
13923      var container = document.getElementById('code-particles');
13924      if (!container) return;
13925      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
13926      for (var i = 0; i < 36; i++) {{
13927        (function(idx) {{
13928          var el = document.createElement('span');
13929          el.className = 'code-particle';
13930          el.textContent = snippets[idx % snippets.length];
13931          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
13932          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
13933          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
13934          el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
13935          container.appendChild(el);
13936        }})(i);
13937      }}
13938    }})();
13939
13940    // Settings modal
13941    (function() {{
13942      var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
13943      function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
13944      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
13945      var btn=document.getElementById('settings-btn');if(!btn)return;
13946      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13947      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
13948      document.body.appendChild(m);
13949      var g=document.getElementById('scheme-grid');
13950      if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
13951      var cl=document.getElementById('settings-close');
13952      btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
13953      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13954      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13955    }})();
13956
13957    // Watched folder picker
13958    (function() {{
13959      var btn = document.getElementById('add-watched-btn');
13960      if (!btn) return;
13961      btn.addEventListener('click', function() {{
13962        fetch('/pick-directory?kind=reports')
13963          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
13964          .then(function(data) {{
13965            if (!data.cancelled && data.selected_path) {{
13966              var form = document.createElement('form');
13967              form.method = 'POST';
13968              form.action = '/watched-dirs/add';
13969              var ri = document.createElement('input');
13970              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
13971              var fi = document.createElement('input');
13972              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
13973              form.appendChild(ri); form.appendChild(fi);
13974              document.body.appendChild(form);
13975              form.submit();
13976            }}
13977          }})
13978          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
13979      }});
13980    }})();
13981  }})();
13982  </script>
13983
13984  <script src="/static/chart.js" nonce="{nonce}"></script>
13985  <script nonce="{nonce}">
13986  (function() {{
13987    var SCOPE_DATA = {scope_data_json};
13988    var currentRoot = '__all__';
13989    var currentSub  = '';
13990    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
13991    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
13992    var ALL_CHARTS = [];
13993    var currentLangTests = [];
13994    var currentTrendPts = [];
13995
13996    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
13997    function fmtFull(n){{return Number(n).toLocaleString();}}
13998    function isDark(){{return document.body.classList.contains('dark-theme');}}
13999    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14000    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14001    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14002
14003    function makeDlPlugin(fmtFn, anchor) {{
14004      return {{
14005        afterDatasetsDraw: function(chart) {{
14006          var ctx = chart.ctx;
14007          var tc = txtClr();
14008          chart.data.datasets.forEach(function(ds, di) {{
14009            var meta = chart.getDatasetMeta(di);
14010            meta.data.forEach(function(el, idx) {{
14011              var label = fmtFn(ds.data[idx], di, idx);
14012              if (label == null || label === '') return;
14013              ctx.save();
14014              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14015              ctx.fillStyle = tc;
14016              if (anchor === 'top') {{
14017                ctx.textAlign = 'center';
14018                ctx.textBaseline = 'bottom';
14019                ctx.fillText(String(label), el.x, el.y - 5);
14020              }} else {{
14021                ctx.textAlign = 'left';
14022                ctx.textBaseline = 'middle';
14023                ctx.fillText(String(label), el.x + 5, el.y);
14024              }}
14025              ctx.restore();
14026            }});
14027          }});
14028        }}
14029      }};
14030    }}
14031
14032    // Cursor: pointer over chart data, default over empty chart area.
14033    function chartCursor(e, els) {{
14034      var t = e.native && e.native.target;
14035      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14036    }}
14037    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14038
14039    // Plugin: draws % labels inside each doughnut slice.
14040    var donutPctPlugin = {{
14041      afterDatasetsDraw: function(chart) {{
14042        var ctx = chart.ctx;
14043        chart.data.datasets.forEach(function(ds, di) {{
14044          var meta = chart.getDatasetMeta(di);
14045          if (meta.hidden) return;
14046          var total = 0;
14047          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14048          if (!total) return;
14049          meta.data.forEach(function(arc, i) {{
14050            if (arc.hidden) return;
14051            var val = ds.data[i] || 0;
14052            var pct = val / total * 100;
14053            if (pct < 3) return;
14054            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14055            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14056            var tx = arc.x + midR * Math.cos(midAngle);
14057            var ty = arc.y + midR * Math.sin(midAngle);
14058            ctx.save();
14059            ctx.textAlign = 'center';
14060            ctx.textBaseline = 'middle';
14061            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14062            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14063            ctx.shadowBlur = 3;
14064            ctx.fillStyle = '#fff';
14065            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14066            ctx.restore();
14067          }});
14068        }});
14069      }}
14070    }};
14071
14072    function makeTmOverlay(title, subtitle, h) {{
14073      var overlay = document.createElement('div');
14074      overlay.className = 'chart-modal-overlay';
14075      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14076      var ch = Math.min(h || 560, maxH);
14077      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14078      overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">&times;</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14079      document.body.appendChild(overlay);
14080      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14081      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14082      return document.getElementById('tm-modal-canvas');
14083    }}
14084
14085    function getDataset() {{
14086      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14087      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14088      return r;
14089    }}
14090    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14091
14092    function showNoData(id, show) {{
14093      var el = document.getElementById(id);
14094      if (!el) return;
14095      var wrap = el.previousElementSibling;
14096      el.style.display = show ? '' : 'none';
14097      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14098    }}
14099
14100    function renderTestCharts(D) {{
14101      currentLangTests = D || [];
14102      testsChart = destroyChart(testsChart);
14103      densityChart = destroyChart(densityChart);
14104      if (!D || !D.length) {{
14105        showNoData('no-data-tests', true);
14106        showNoData('no-data-density', true);
14107        return;
14108      }}
14109      showNoData('no-data-tests', false);
14110      showNoData('no-data-density', false);
14111      var top15 = D.slice(0, 15);
14112      var canvas1 = document.getElementById('canvas-tests');
14113      if (canvas1) {{
14114        testsChart = new Chart(canvas1, {{
14115          type: 'bar',
14116          data: {{
14117            labels: top15.map(function(d){{ return d.lang; }}),
14118            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14119          }},
14120          options: {{
14121            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14122            layout: {{ padding: {{ right: 64 }} }},
14123            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14124            scales: {{
14125              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14126              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14127            }}
14128          }},
14129          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14130        }});
14131        ALL_CHARTS.push(testsChart);
14132      }}
14133      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14134      var canvas2 = document.getElementById('canvas-density');
14135      if (canvas2) {{
14136        densityChart = new Chart(canvas2, {{
14137          type: 'bar',
14138          data: {{
14139            labels: topD.map(function(d){{ return d.lang; }}),
14140            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14141          }},
14142          options: {{
14143            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14144            layout: {{ padding: {{ right: 64 }} }},
14145            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14146            scales: {{
14147              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14148              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14149            }}
14150          }},
14151          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14152        }});
14153        ALL_CHARTS.push(densityChart);
14154      }}
14155    }}
14156
14157    function renderAssertionsChart(D) {{
14158      assertionsChart = destroyChart(assertionsChart);
14159      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14160      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14161      var canvas = document.getElementById('canvas-assertions');
14162      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14163      showNoData('no-data-assertions', false);
14164      assertionsChart = new Chart(canvas, {{
14165        type: 'bar',
14166        data: {{
14167          labels: top15.map(function(d){{ return d.lang; }}),
14168          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14169        }},
14170        options: {{
14171          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14172          layout: {{ padding: {{ right: 64 }} }},
14173          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14174          scales: {{
14175            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14176            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14177          }}
14178        }},
14179        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14180      }});
14181      ALL_CHARTS.push(assertionsChart);
14182    }}
14183
14184    function renderSuitesChart(D) {{
14185      suitesChart = destroyChart(suitesChart);
14186      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14187      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14188      var canvas = document.getElementById('canvas-suites');
14189      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14190      showNoData('no-data-suites', false);
14191      suitesChart = new Chart(canvas, {{
14192        type: 'bar',
14193        data: {{
14194          labels: top15.map(function(d){{ return d.lang; }}),
14195          datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14196        }},
14197        options: {{
14198          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14199          layout: {{ padding: {{ right: 64 }} }},
14200          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14201          scales: {{
14202            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14203            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14204          }}
14205        }},
14206        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14207      }});
14208      ALL_CHARTS.push(suitesChart);
14209    }}
14210
14211    function renderFilesChart(totals) {{
14212      filesChart = destroyChart(filesChart);
14213      var canvas = document.getElementById('canvas-files');
14214      if (!canvas) return;
14215      var testF = totals.test_files || 0;
14216      var totalF = totals.total_files || 0;
14217      var nonTest = Math.max(0, totalF - testF);
14218      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14219      showNoData('no-data-files', false);
14220      var dark = isDark();
14221      filesChart = new Chart(canvas, {{
14222        type: 'doughnut',
14223        data: {{
14224          labels: ['Test Files', 'Non-Test Files'],
14225          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14226        }},
14227        options: {{
14228          responsive: true, maintainAspectRatio: false, cutout: '62%',
14229          onHover: chartCursor,
14230          plugins: {{
14231            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14232              generateLabels: function(chart) {{
14233                var ds = chart.data.datasets[0];
14234                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14235                return chart.data.labels.map(function(lbl, i) {{
14236                  var val = ds.data[i] || 0;
14237                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14238                  return {{
14239                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14240                    fillStyle: ds.backgroundColor[i],
14241                    strokeStyle: ds.borderColor,
14242                    lineWidth: ds.borderWidth,
14243                    hidden: false,
14244                    index: i,
14245                    datasetIndex: 0
14246                  }};
14247                }});
14248              }}
14249            }},
14250              onHover: function(e, item, leg) {{
14251                var ch = leg.chart;
14252                var t = e.native && e.native.target;
14253                if (t) t.style.cursor = 'pointer';
14254                ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14255                ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14256                ch.update();
14257              }},
14258              onLeave: function(e, item, leg) {{
14259                var ch = leg.chart;
14260                var t = e.native && e.native.target;
14261                if (t) t.style.cursor = 'default';
14262                ch.setActiveElements([]);
14263                ch.tooltip.setActiveElements([], {{}});
14264                ch.update('none');
14265              }}
14266            }},
14267            tooltip: {{ callbacks: {{ label: function(ctx) {{
14268              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14269              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14270            }} }} }}
14271          }}
14272        }},
14273        plugins: [donutPctPlugin]
14274      }});
14275      ALL_CHARTS.push(filesChart);
14276    }}
14277
14278    function renderCompositionChart(totals) {{
14279      compositionChart = destroyChart(compositionChart);
14280      var canvas = document.getElementById('canvas-composition');
14281      if (!canvas) return;
14282      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14283      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14284      showNoData('no-data-composition', false);
14285      compositionChart = new Chart(canvas, {{
14286        type: 'bar',
14287        data: {{
14288          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14289          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14290        }},
14291        options: {{
14292          responsive: true, maintainAspectRatio: false,
14293          layout: {{ padding: {{ top: 22 }} }},
14294          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14295          scales: {{
14296            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14297            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14298          }}
14299        }},
14300        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14301      }});
14302      ALL_CHARTS.push(compositionChart);
14303    }}
14304
14305    function renderCovCharts(covD, tiers) {{
14306      covChart = destroyChart(covChart);
14307      tierChart = destroyChart(tierChart);
14308      var covCanvas = document.getElementById('canvas-cov');
14309      if (covCanvas && covD && covD.length) {{
14310        covChart = new Chart(covCanvas, {{
14311          type: 'bar',
14312          data: {{
14313            labels: covD.map(function(d){{ return d.lang; }}),
14314            datasets: [{{ label: 'Line Coverage %', data: covD.map(function(d){{ return d.pct; }}), backgroundColor: covD.map(function(d){{ return d.pct >= 80 ? '#2A6846' : d.pct >= 50 ? '#D4A017' : '#B23030'; }}), borderRadius: 4 }}]
14315          }},
14316          options: {{
14317            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14318            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14319            scales: {{
14320              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14321              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14322            }}
14323          }}
14324        }});
14325        ALL_CHARTS.push(covChart);
14326      }}
14327      var tierCanvas = document.getElementById('canvas-cov-tiers');
14328      if (tierCanvas && tiers) {{
14329        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14330        tierChart = new Chart(tierCanvas, {{
14331          type: 'doughnut',
14332          data: {{
14333            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14334            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14335          }},
14336          options: {{
14337            responsive: true, maintainAspectRatio: false, cutout: '62%',
14338            onHover: chartCursor,
14339            plugins: {{
14340              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14341                onHover: function(e, item, leg) {{
14342                  var ch = leg.chart;
14343                  var t = e.native && e.native.target;
14344                  if (t) t.style.cursor = 'pointer';
14345                  ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14346                  ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14347                  ch.update();
14348                }},
14349                onLeave: function(e, item, leg) {{
14350                  var ch = leg.chart;
14351                  var t = e.native && e.native.target;
14352                  if (t) t.style.cursor = 'default';
14353                  ch.setActiveElements([]);
14354                  ch.tooltip.setActiveElements([], {{}});
14355                  ch.update('none');
14356                }}
14357              }},
14358              tooltip: {{ callbacks: {{ label: function(ctx) {{
14359                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14360                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14361              }} }} }}
14362            }}
14363          }},
14364          plugins: [donutPctPlugin]
14365        }});
14366        ALL_CHARTS.push(tierChart);
14367      }}
14368    }}
14369
14370    function buildLangTable(D) {{
14371      var tbody = document.getElementById('lang-tbody');
14372      if (!tbody) return;
14373      if (!D || !D.length) {{
14374        tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
14375        return;
14376      }}
14377      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14378      tbody.innerHTML = D.map(function(d) {{
14379        var barW = Math.round(d.density / maxDensity * 120);
14380        return '<tr>' +
14381          '<td><strong>' + d.lang + '</strong></td>' +
14382          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14383          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14384          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14385          '<td class="num">' + fmtFull(d.code) + '</td>' +
14386          '<td class="num">' + fmtFull(d.files) + '</td>' +
14387          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14388          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14389          '</tr>';
14390      }}).join('');
14391    }}
14392
14393    var covFileData = [];
14394    var covFileTier = 'all';
14395    var covFileSearch = '';
14396
14397    function pctBadge(pct) {{
14398      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14399      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14400      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14401    }}
14402
14403    function buildCovFileTable() {{
14404      var tbody = document.getElementById('cov-file-tbody');
14405      var empty = document.getElementById('cov-file-empty');
14406      var count = document.getElementById('cov-file-count');
14407      if (!tbody) return;
14408      var srch = covFileSearch.toLowerCase();
14409      var filtered = covFileData.filter(function(f) {{
14410        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14411        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14412        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14413        if (covFileTier === 'high' && f.line_pct < 80) return false;
14414        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14415        return true;
14416      }});
14417      if (!filtered.length) {{
14418        tbody.innerHTML = '';
14419        if (empty) empty.style.display = '';
14420        if (count) count.textContent = '';
14421        return;
14422      }}
14423      if (empty) empty.style.display = 'none';
14424      var shown = Math.min(filtered.length, 500);
14425      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14426      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14427        var fnCol = f.fn_pct < 0
14428          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14429          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14430        return '<tr>' +
14431          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
14432          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14433          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14434          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14435          fnCol +
14436          '</tr>';
14437      }}).join('');
14438    }}
14439
14440    (function() {{
14441      var tabs = document.getElementById('cov-filter-tabs');
14442      if (tabs) {{
14443        tabs.addEventListener('click', function(e) {{
14444          var btn = e.target.closest('.cov-tab');
14445          if (!btn) return;
14446          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14447          btn.classList.add('active');
14448          covFileTier = btn.getAttribute('data-tier');
14449          buildCovFileTable();
14450        }});
14451      }}
14452      var srch = document.getElementById('cov-file-search');
14453      if (srch) {{
14454        srch.addEventListener('input', function() {{
14455          covFileSearch = this.value;
14456          buildCovFileTable();
14457        }});
14458      }}
14459    }})();
14460
14461    function updateCovGauges(t) {{
14462      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14463      var el;
14464      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14465      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14466      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14467      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14468      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14469      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14470    }}
14471
14472    function applyScope() {{
14473      var d = getDataset();
14474      var t = d.totals;
14475      var el;
14476      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14477      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14478      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14479      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14480      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14481      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14482      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14483      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14484      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14485      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14486      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14487      renderTestCharts(d.lang_tests);
14488      renderAssertionsChart(d.lang_tests);
14489      renderSuitesChart(d.lang_tests);
14490      renderFilesChart(t);
14491      renderCompositionChart(t);
14492      buildLangTable(d.lang_tests);
14493      var covPanel = document.getElementById('cov-panel');
14494      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14495      if (d.has_coverage) {{
14496        renderCovCharts(d.cov, d.cov_tiers);
14497        updateCovGauges(t);
14498        covFileData = d.file_cov || [];
14499        covFileTier = 'all';
14500        covFileSearch = '';
14501        var tabs = document.getElementById('cov-filter-tabs');
14502        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14503        var srch = document.getElementById('cov-file-search');
14504        if (srch) srch.value = '';
14505        buildCovFileTable();
14506      }}
14507      loadTrend();
14508    }}
14509
14510    // Populate scope-root-sel from SCOPE_DATA keys
14511    (function() {{
14512      var sel = document.getElementById('scope-root-sel');
14513      if (!sel) return;
14514      Object.keys(SCOPE_DATA).forEach(function(k) {{
14515        if (k === '__all__') return;
14516        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14517      }});
14518    }})();
14519
14520    document.getElementById('scope-root-sel').addEventListener('change', function() {{
14521      currentRoot = this.value;
14522      currentSub = '';
14523      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14524      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14525      var subWrap = document.getElementById('scope-sub-wrap');
14526      var subSel  = document.getElementById('scope-sub-sel');
14527      subSel.innerHTML = '<option value="">Entire project</option>';
14528      if (subNames.length) {{
14529        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14530        subWrap.style.display = 'flex';
14531      }} else {{
14532        subWrap.style.display = 'none';
14533      }}
14534      applyScope();
14535    }});
14536
14537    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14538      currentSub = this.value;
14539      applyScope();
14540    }});
14541
14542    var allTrendData = [];
14543
14544    var TM_Y_META = {{
14545      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14546      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
14547    }};
14548
14549    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14550    function hexRgb(hex) {{
14551      var h = String(hex).replace('#', '');
14552      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14553      var n = parseInt(h, 16);
14554      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14555    }}
14556    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14557    // tint at the top to transparent at the bottom (no flat solid block).
14558    function tmTrendGradient(ctx2, chartArea, color) {{
14559      var rgb = hexRgb(color);
14560      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14561      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
14562      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14563      g.addColorStop(1,   'rgba(' + rgb + ',0)');
14564      return g;
14565    }}
14566
14567    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14568    // so linear interpolation between adjacent points matches the drawn line).
14569    function tmLineYAt(chart, px) {{
14570      var meta = chart.getDatasetMeta(0);
14571      if (!meta || !meta.data || !meta.data.length) return null;
14572      var d = meta.data;
14573      if (px <= d[0].x) return d[0].y;
14574      for (var i = 1; i < d.length; i++) {{
14575        if (px <= d[i].x) {{
14576          var span = d[i].x - d[i - 1].x;
14577          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14578          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14579        }}
14580      }}
14581      return d[d.length - 1].y;
14582    }}
14583
14584    // Plugin: only show the tooltip / finger cursor when the pointer is over the
14585    // gradient fill (inside the plot and at/below the line) — never in the empty
14586    // space above the line. Outside the fill we retype the event as 'mouseout' so
14587    // the core interaction dismisses any active tooltip on its own.
14588    var tmFillGuard = {{
14589      id: 'tmFillGuard',
14590      beforeEvent: function(chart, args) {{
14591        var e = args.event;
14592        if (!e || e.type !== 'mousemove') return;
14593        var ca = chart.chartArea;
14594        if (!ca) return;
14595        var inFill = false;
14596        if (e.x >= ca.left && e.x <= ca.right) {{
14597          var ly = tmLineYAt(chart, e.x);
14598          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14599        }}
14600        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14601        if (!inFill) {{ e.type = 'mouseout'; }}
14602      }}
14603    }};
14604
14605    // Single source of truth for the test-metrics trend chart config so the inline
14606    // chart and the Full View modal render identically (straight segments, gradient
14607    // fill, white-ringed points, gradient-only interactivity).
14608    function buildTmTrendConfig(pts, ctrl, meta) {{
14609      return {{
14610        type: 'line',
14611        data: {{
14612          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14613          datasets: [{{
14614            label: meta.label,
14615            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14616            borderColor: meta.color,
14617            borderWidth: 2.5,
14618            backgroundColor: function(context) {{
14619              var ca = context.chart.chartArea;
14620              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14621              return tmTrendGradient(context.chart.ctx, ca, meta.color);
14622            }},
14623            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14624            pointBorderColor: '#fff',
14625            pointBorderWidth: 2,
14626            pointRadius: 6,
14627            pointHoverRadius: 9,
14628            pointHoverBorderWidth: 2.5,
14629            fill: true, tension: 0
14630          }}]
14631        }},
14632        options: {{
14633          responsive: true, maintainAspectRatio: false,
14634          layout: {{ padding: {{ top: 22 }} }},
14635          interaction: {{ mode: 'index', intersect: false }},
14636          plugins: {{
14637            legend: {{ display: false }},
14638            tooltip: {{
14639              mode: 'index', intersect: false,
14640              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14641            }}
14642          }},
14643          scales: {{
14644            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14645            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14646          }}
14647        }},
14648        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14649      }};
14650    }}
14651
14652    function getTrendControls() {{
14653      var ySel    = document.getElementById('tm-trend-y');
14654      var xSel    = document.getElementById('tm-trend-x');
14655      var sizeSel = document.getElementById('tm-trend-size');
14656      var subSel  = document.getElementById('tm-trend-sub');
14657      return {{
14658        yKey:    ySel    ? ySel.value    : 'test_count',
14659        xMode:   xSel    ? xSel.value    : 'commit',
14660        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
14661        submod:  subSel  ? subSel.value  : ''
14662      }};
14663    }}
14664
14665    function makeTrendLabel(d, xMode) {{
14666      if (xMode === 'commit') {{
14667        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14668      }}
14669      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14670    }}
14671
14672    function buildTrend(data) {{
14673      allTrendData = data || [];
14674      renderTrend();
14675    }}
14676
14677    function renderTrend() {{
14678      var data = allTrendData;
14679      var ctrl = getTrendControls();
14680      var trendCanvas = document.getElementById('canvas-trend');
14681      var trendWrap   = document.getElementById('trend-canvas-wrap');
14682      var trendEmpty  = document.getElementById('trend-empty');
14683
14684      // Apply chart size
14685      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14686
14687      // Filter by submodule if selected (entries from project_label match)
14688      var pts = data.slice().reverse();
14689      if (ctrl.submod) {{
14690        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14691      }}
14692
14693      currentTrendPts = pts;
14694
14695      if (!pts.length) {{
14696        if (trendCanvas) trendCanvas.style.display = 'none';
14697        if (trendEmpty) trendEmpty.style.display = '';
14698        return;
14699      }}
14700      if (trendCanvas) trendCanvas.style.display = '';
14701      if (trendEmpty) trendEmpty.style.display = 'none';
14702
14703      trendChart = destroyChart(trendChart);
14704      if (!trendCanvas) return;
14705
14706      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14707
14708      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14709      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14710      ALL_CHARTS.push(trendChart);
14711
14712      // Populate submodule selector from unique project_labels
14713      var subSel = document.getElementById('tm-trend-sub');
14714      var subLabel = document.getElementById('tm-sub-label');
14715      if (subSel && data.length) {{
14716        var projects = [];
14717        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14718        if (projects.length > 1) {{
14719          var curVal = subSel.value;
14720          subSel.innerHTML = '<option value="">All (project total)</option>';
14721          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14722          if (subLabel) subLabel.style.display = '';
14723        }} else {{
14724          if (subLabel) subLabel.style.display = 'none';
14725        }}
14726      }}
14727    }}
14728
14729    // ── Full View expand buttons ──────────────────────────────────────────────
14730    (function() {{
14731      var btn = document.getElementById('tests-expand-btn');
14732      if (!btn) return;
14733      btn.addEventListener('click', function() {{
14734        var D = currentLangTests;
14735        if (!D || !D.length) return;
14736        var top15 = D.slice(0, 15);
14737        var h = Math.max(320, top15.length * 36 + 80);
14738        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14739        if (!canvas) return;
14740        new Chart(canvas, {{
14741          type: 'bar',
14742          data: {{
14743            labels: top15.map(function(d){{ return d.lang; }}),
14744            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14745          }},
14746          options: {{
14747            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14748            layout: {{ padding: {{ right: 72 }} }},
14749            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14750            scales: {{
14751              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14752              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14753            }}
14754          }},
14755          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14756        }});
14757      }});
14758    }})();
14759
14760    (function() {{
14761      var btn = document.getElementById('density-expand-btn');
14762      if (!btn) return;
14763      btn.addEventListener('click', function() {{
14764        var D = currentLangTests;
14765        if (!D || !D.length) return;
14766        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14767        var h = Math.max(320, topD.length * 36 + 80);
14768        var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14769        if (!canvas) return;
14770        new Chart(canvas, {{
14771          type: 'bar',
14772          data: {{
14773            labels: topD.map(function(d){{ return d.lang; }}),
14774            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14775          }},
14776          options: {{
14777            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14778            layout: {{ padding: {{ right: 72 }} }},
14779            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14780            scales: {{
14781              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14782              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14783            }}
14784          }},
14785          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14786        }});
14787      }});
14788    }})();
14789
14790    (function() {{
14791      var btn = document.getElementById('trend-expand-btn');
14792      if (!btn) return;
14793      btn.addEventListener('click', function() {{
14794        var pts = currentTrendPts;
14795        if (!pts || !pts.length) return;
14796        var ctrl = getTrendControls();
14797        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14798        var title = meta.label + ' Trend \u2014 Full View';
14799        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14800        if (!canvas) return;
14801        // Reuse the exact inline-chart config so Full View matches the default view
14802        // (straight segments + gradient-only interactivity), just larger.
14803        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14804      }});
14805    }})();
14806
14807    (function() {{
14808      var btn = document.getElementById('assertions-expand-btn');
14809      if (!btn) return;
14810      btn.addEventListener('click', function() {{
14811        var D = currentLangTests;
14812        if (!D || !D.length) return;
14813        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14814        if (!top15.length) return;
14815        var h = Math.max(320, top15.length * 36 + 80);
14816        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14817        if (!canvas) return;
14818        new Chart(canvas, {{
14819          type: 'bar',
14820          data: {{
14821            labels: top15.map(function(d){{ return d.lang; }}),
14822            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14823          }},
14824          options: {{
14825            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14826            layout: {{ padding: {{ right: 72 }} }},
14827            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14828            scales: {{
14829              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14830              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14831            }}
14832          }},
14833          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14834        }});
14835      }});
14836    }})();
14837
14838    (function() {{
14839      var btn = document.getElementById('suites-expand-btn');
14840      if (!btn) return;
14841      btn.addEventListener('click', function() {{
14842        var D = currentLangTests;
14843        if (!D || !D.length) return;
14844        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14845        if (!top15.length) return;
14846        var h = Math.max(320, top15.length * 36 + 80);
14847        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
14848        if (!canvas) return;
14849        new Chart(canvas, {{
14850          type: 'bar',
14851          data: {{
14852            labels: top15.map(function(d){{ return d.lang; }}),
14853            datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14854          }},
14855          options: {{
14856            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14857            layout: {{ padding: {{ right: 72 }} }},
14858            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14859            scales: {{
14860              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14861              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14862            }}
14863          }},
14864          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14865        }});
14866      }});
14867    }})();
14868
14869    // Wire trend control selectors — re-render without re-fetching
14870    (function() {{
14871      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
14872        var el = document.getElementById(id);
14873        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
14874      }});
14875    }})();
14876
14877    function loadTrend() {{
14878      var url = '/api/metrics/history?limit=100';
14879      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
14880      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
14881        buildTrend(data);
14882        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
14883        var btn = document.getElementById('multi-compare-trend-btn');
14884        if (btn) {{
14885          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
14886          if (ids.length >= 2) {{
14887            btn.style.display = '';
14888            btn.onclick = function() {{
14889              // Reverse so oldest first (API returns newest first).
14890              var sorted = ids.slice().reverse();
14891              if (sorted.length === 2) {{
14892                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
14893              }} else {{
14894                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
14895              }}
14896            }};
14897          }} else {{
14898            btn.style.display = 'none';
14899          }}
14900        }}
14901      }}).catch(function(){{
14902        var trendEmpty = document.getElementById('trend-empty');
14903        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
14904      }});
14905    }}
14906
14907    // Re-render charts on theme toggle
14908    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
14909      setTimeout(function() {{
14910        ALL_CHARTS.forEach(function(c) {{
14911          if (c && c.options && c.options.scales) {{
14912            Object.values(c.options.scales).forEach(function(ax) {{
14913              if (ax.grid) ax.grid.color = clr();
14914              if (ax.ticks) ax.ticks.color = txtClr();
14915            }});
14916            c.update();
14917          }}
14918        }});
14919      }}, 80);
14920    }});
14921
14922    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
14923    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14924    function tmExportMeta() {{
14925      var sel = document.getElementById('scope-sel');
14926      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
14927      if (!proj || proj === '__all__') proj = 'All projects';
14928      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
14929      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
14930      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
14931      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
14932      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
14933    }}
14934
14935    function exportTmXLSX() {{
14936      var D = currentLangTests;
14937      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
14938      var t = tmExportMeta();
14939      function s2b(s) {{ return new TextEncoder().encode(s); }}
14940      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
14941      function col2l(n) {{ var s=''; while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}} return s; }}
14942      function crc32(d) {{
14943        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
14944        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
14945      }}
14946      // Store all cells as strings so Excel left-aligns uniformly.
14947      function cs(addr, val, bold) {{
14948        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
14949      }}
14950      // Build an Excel Table XML definition for a given sheet range and columns.
14951      function makeTableXml(tblId, name, ref, cols) {{
14952        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
14953        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14954        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
14955        x+='<autoFilter ref="'+ref+'"/>';
14956        x+='<tableColumns count="'+cols.length+'">';
14957        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
14958        x+='</tableColumns>';
14959        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
14960        return x+'</table>';
14961      }}
14962      // Worksheet XML with optional Excel Table part reference.
14963      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
14964        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14965        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
14966        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
14967        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
14968        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
14969        rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
14970        if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
14971        x+='</sheetData>';
14972        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
14973        return x+'</worksheet>';
14974      }}
14975
14976      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
14977      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
14978      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
14979      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
14980      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
14981      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
14982
14983      // Sheet 1: Summary
14984      var sumHdr=['Metric','Value'];
14985      var sumRows=[
14986        ['Project / Scope', t.proj],
14987        ['Export Date', t.full],
14988        ['Test Functions', Number(totTests).toLocaleString()],
14989        ['Assertions', Number(totAssert).toLocaleString()],
14990        ['Test Suites', Number(totSuites).toLocaleString()],
14991        ['Languages with Tests', String(D.length)],
14992        ['Total Code Lines', Number(totCode).toLocaleString()],
14993        ['Average Density (per 1K)', String(avgDensity)],
14994      ];
14995      var sumRef='A1:B'+(sumRows.length+1);
14996      var sumTblXml=makeTableXml(1,'Summary',sumRef,sumHdr);
14997      var sumSheetXml=buildSheet(sumHdr,sumRows,null,[28,22],'rId1');
14998
14999      // Sheet 2: Language Breakdown
15000      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15001      var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
15002      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15003      // Table covers header + data only (TOTAL row excluded from table, sits just below)
15004      var langDataRows=langRows.length;
15005      var langTblRef='A1:G'+(langDataRows+1);
15006      var langTblXml=makeTableXml(2,'LangBreakdown',langTblRef,langHdr);
15007      var langSheetXml=buildSheet(langHdr,langRows,totRow,[22,15,15,15,15,12,15],'rId1');
15008
15009      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
15010      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/tables/table2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15011      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
15012      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
15013      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="Language Breakdown" sheetId="2" r:id="rId2"/></sheets></workbook>';
15014      var sh1rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/></Relationships>';
15015      var sh2rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table2.xml"/></Relationships>';
15016      var files=[
15017        {{name:'[Content_Types].xml',data:s2b(ct)}},
15018        {{name:'_rels/.rels',data:s2b(dotrels)}},
15019        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15020        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15021        {{name:'xl/styles.xml',data:s2b(styl)}},
15022        {{name:'xl/worksheets/sheet1.xml',data:s2b(sumSheetXml)}},
15023        {{name:'xl/worksheets/sheet2.xml',data:s2b(langSheetXml)}},
15024        {{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b(sh1rels)}},
15025        {{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b(sh2rels)}},
15026        {{name:'xl/tables/table1.xml',data:s2b(sumTblXml)}},
15027        {{name:'xl/tables/table2.xml',data:s2b(langTblXml)}},
15028      ];
15029      var parts=[],offsets=[],total=0;
15030      files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15031      var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15032      var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15033      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15034      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15035      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15036      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15037      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15038    }}
15039
15040    function exportTmPNG() {{
15041      // Map canvas IDs to display titles
15042      var CHART_TITLES = {{
15043        'canvas-trend':       'TEST COUNT TREND',
15044        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15045        'canvas-density':     'TEST DENSITY (PER 1,000 CODE LINES)',
15046        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15047        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15048        'canvas-files':       'TEST FILES BREAKDOWN',
15049        'canvas-composition': 'TEST COMPOSITION'
15050      }};
15051      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15052      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15053      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15054      var t=tmExportMeta();
15055      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15056      var trendCanvas=document.getElementById('canvas-trend');
15057      var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15058      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15059      var TOTAL_W=COLW*2+GAP;
15060      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15061      TREND_H=Math.min(Math.max(200,TREND_H),340);
15062      // Per-row chart heights (2-col grid)
15063      var gridRows=Math.ceil(gridCanvases.length/2);
15064      var rowHeights=[];
15065      for(var ri=0;ri<gridRows;ri++){{
15066        var rh=240;
15067        for(var ci=0;ci<2;ci++){{
15068          var cv=gridCanvases[ri*2+ci];
15069          if(cv&&cv.width>0){{
15070            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15071            rh=Math.max(rh,Math.min(420,nat));
15072          }}
15073        }}
15074        rowHeights.push(rh);
15075      }}
15076      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15077      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15078      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15079      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15080      var ctx=out.getContext('2d');
15081      var cs2=getComputedStyle(document.body);
15082      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15083      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15084      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15085
15086      // Background
15087      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15088
15089      // Orange header block
15090      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15091      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15092      ctx.fillText('Test Metrics — '+t.proj,22,42);
15093      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15094      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15095      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15096
15097      // Helper: draw a section title label
15098      function drawTitle(label, x, y, w) {{
15099        ctx.save();
15100        ctx.fillStyle=oxide;
15101        ctx.font='700 11px '+TM_FONT;
15102        ctx.textBaseline='middle';
15103        ctx.textAlign='left';
15104        ctx.letterSpacing='0.07em';
15105        ctx.fillText(label, x+2, y+TITLE_H/2);
15106        // Underline
15107        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15108        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15109        ctx.globalAlpha=1;
15110        ctx.restore();
15111      }}
15112
15113      var yOff=HEADER_H;
15114
15115      // Trend chart (full width)
15116      if(hasTrend){{
15117        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15118        yOff+=TITLE_H;
15119        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15120        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15121        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15122        ctx.drawImage(surf,0,yOff);
15123        yOff+=TREND_H+ROW_PAD;
15124      }}
15125
15126      // Grid charts (2-col), each cell gets title + chart
15127      for(var gi=0;gi<gridRows;gi++){{
15128        var rh2=rowHeights[gi];
15129        // Draw row titles and charts
15130        for(var gci=0;gci<2;gci++){{
15131          var idx2=gi*2+gci;
15132          if(idx2>=gridCanvases.length)continue;
15133          var gcv=gridCanvases[idx2];
15134          var gx=gci*(COLW+GAP);
15135          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15136        }}
15137        yOff+=TITLE_H;
15138        for(var gci2=0;gci2<2;gci2++){{
15139          var idx3=gi*2+gci2;
15140          if(idx3>=gridCanvases.length)continue;
15141          var gcv2=gridCanvases[idx3];
15142          var gx2=gci2*(COLW+GAP);
15143          var natW=gcv2.width,natH=gcv2.height;
15144          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15145          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15146          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15147          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15148          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15149          ctx.drawImage(surf2,gx2,yOff);
15150        }}
15151        yOff+=rh2+ROW_PAD;
15152      }}
15153
15154      // Dark footer
15155      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15156      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15157      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15158
15159      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15160      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15161    }}
15162
15163    function exportTmPDF() {{
15164      var D=currentLangTests;
15165      var t=tmExportMeta();
15166      var strips=document.querySelectorAll('.summary-strip');
15167      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15168      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15169      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15170      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15171      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15172      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15173      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15174      var rows='';
15175      (D||[]).forEach(function(d){{
15176        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15177          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15178          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15179          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15180          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15181          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15182          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15183      }});
15184      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15185        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15186        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15187        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15188        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15189        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15190        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15191      var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15192      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15193        +'html,body{{height:100%;margin:0;}}'
15194        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15195        +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15196        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15197        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15198        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15199        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15200        +'.rep-body{{padding:20px 32px;flex:1;}}'
15201        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15202        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15203        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15204        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15205        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15206        +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15207        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15208        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15209        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15210        +'.n{{text-align:right;}}'
15211        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15212        +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15213        +'</style>';
15214      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15215        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15216        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15217        +'<div class="rep-body">'+statsHtml
15218        +'<div class="section-hdr">Language Breakdown</div>'
15219        +tableHtml+'</div>'
15220        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15221        +'</body></html>';
15222      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15223      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:document.getElementById('tm-export-pdf-btn')}});
15224    }}
15225
15226    (function() {{
15227      var xBtn=document.getElementById('tm-export-xlsx-btn');
15228      var pngBtn=document.getElementById('tm-export-png-btn');
15229      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15230      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15231      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15232      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15233    }})();
15234
15235    applyScope();
15236  }})();
15237  </script>
15238  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15239  {toast_assets}
15240</body>
15241</html>"#,
15242    );
15243    (
15244        [(axum::http::header::CACHE_CONTROL, "no-store")],
15245        Html(html),
15246    )
15247        .into_response()
15248}
15249
15250// ── Embeddable widget ─────────────────────────────────────────────────────────
15251// Protected. Returns a self-contained HTML page suitable for iframing inside
15252// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15253//
15254// GET /embed/summary?run_id=<uuid>&theme=dark
15255
15256#[derive(Deserialize)]
15257struct EmbedQuery {
15258    run_id: Option<String>,
15259    theme: Option<String>,
15260}
15261
15262async fn embed_handler(
15263    State(state): State<AppState>,
15264    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15265    Query(query): Query<EmbedQuery>,
15266) -> Response {
15267    let entry = {
15268        let reg = state.registry.lock().await;
15269        query.run_id.as_ref().map_or_else(
15270            || reg.entries.first().cloned(),
15271            |id| reg.find_by_run_id(id).cloned(),
15272        )
15273    };
15274
15275    let Some(entry) = entry else {
15276        return Html(
15277            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15278                .to_string(),
15279        )
15280        .into_response();
15281    };
15282
15283    let dark = query.theme.as_deref() == Some("dark");
15284    let languages: Vec<(String, u64, u64)> = entry
15285        .json_path
15286        .as_ref()
15287        .and_then(|p| read_json(p).ok())
15288        .map(|run| {
15289            run.totals_by_language
15290                .iter()
15291                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15292                .collect()
15293        })
15294        .unwrap_or_default();
15295
15296    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15297}
15298
15299fn render_embed_widget(
15300    entry: &RegistryEntry,
15301    languages: &[(String, u64, u64)],
15302    dark: bool,
15303    csp_nonce: &str,
15304) -> String {
15305    let s = &entry.summary;
15306    let total = s.code_lines + s.comment_lines + s.blank_lines;
15307    let code_pct = s
15308        .code_lines
15309        .checked_mul(100)
15310        .and_then(|n| n.checked_div(total))
15311        .unwrap_or(0);
15312
15313    let (bg, fg, surface, muted, border) = if dark {
15314        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15315    } else {
15316        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15317    };
15318
15319    let mut lang_rows = String::new();
15320    for (name, files, code) in languages {
15321        write!(
15322            lang_rows,
15323            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15324            escape_html(name),
15325            format_number(*files),
15326            format_number(*code),
15327        )
15328        .ok();
15329    }
15330
15331    let lang_table = if lang_rows.is_empty() {
15332        String::new()
15333    } else {
15334        format!(
15335            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15336        )
15337    };
15338
15339    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15340    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15341    let project_esc = escape_html(&entry.project_label);
15342    let code_lines = format_number(s.code_lines);
15343    let comment_lines = format_number(s.comment_lines);
15344    let files = format_number(s.files_analyzed);
15345    let code_raw = s.code_lines;
15346    let comment_raw = s.comment_lines;
15347    let blank_raw = s.blank_lines;
15348
15349    format!(
15350        r#"<!doctype html>
15351<html lang="en">
15352<head>
15353  <meta charset="utf-8">
15354  <meta name="viewport" content="width=device-width,initial-scale=1">
15355  <title>OxideSLOC &mdash; {project_esc}</title>
15356  <script src="/static/chart.js"></script>
15357  <style nonce="{csp_nonce}">
15358    *{{box-sizing:border-box;margin:0;padding:0}}
15359    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15360    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15361    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15362    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15363    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15364    .card .v{{font-size:18px;font-weight:700}}
15365    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15366    .row{{display:flex;gap:12px;align-items:flex-start}}
15367    .pie{{width:120px;height:120px;flex-shrink:0}}
15368    .lt{{border-collapse:collapse;width:100%;flex:1}}
15369    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15370    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15371    .n{{text-align:right}}
15372    .footer{{margin-top:10px;color:{muted};font-size:10px}}
15373  </style>
15374</head>
15375<body>
15376  <h2>{project_esc}</h2>
15377  <div class="sub">{timestamp} &middot; run {run_short}</div>
15378  <div class="cards">
15379    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15380    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15381    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15382    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15383  </div>
15384  <div class="row">
15385    <canvas class="pie" id="c"></canvas>
15386    {lang_table}
15387  </div>
15388  <div class="footer">oxide-sloc</div>
15389  <script nonce="{csp_nonce}">
15390    new Chart(document.getElementById('c'),{{
15391      type:'doughnut',
15392      data:{{
15393        labels:['Code','Comments','Blank'],
15394        datasets:[{{
15395          data:[{code_raw},{comment_raw},{blank_raw}],
15396          backgroundColor:['#4a78ee','#b35428','#aaa'],
15397          borderWidth:0
15398        }}]
15399      }},
15400      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15401    }});
15402  </script>
15403</body>
15404</html>"#
15405    )
15406}
15407
15408/// Returns a process-wide mutex unique to `dir`, so that two requests writing
15409/// artifacts into the *same* output directory (e.g. re-ingesting an identical
15410/// `run_id`) serialize instead of corrupting each other's files. Directories that
15411/// differ never contend, so legitimate parallel analyses keep their throughput.
15412fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15413    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15414        OnceLock::new();
15415    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15416    let mut guard = map
15417        .lock()
15418        .unwrap_or_else(std::sync::PoisonError::into_inner);
15419    guard
15420        .entry(dir.to_path_buf())
15421        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15422        .clone()
15423}
15424
15425#[allow(clippy::too_many_lines)]
15426fn persist_run_artifacts(
15427    run: &sloc_core::AnalysisRun,
15428    report_html: &str,
15429    run_dir: &Path,
15430    report_title: &str,
15431    file_stem: &str,
15432    result_context: RunResultContext,
15433) -> Result<(RunArtifacts, PendingPdf)> {
15434    // Serialize concurrent writers targeting this same output directory so their
15435    // file writes cannot interleave and corrupt one another.
15436    let dir_lock = output_dir_lock(run_dir);
15437    let _dir_guard = dir_lock
15438        .lock()
15439        .unwrap_or_else(std::sync::PoisonError::into_inner);
15440
15441    // Root dir + organised subdirectories.
15442    let html_dir = run_dir.join("html");
15443    let pdf_dir = run_dir.join("pdf");
15444    let excel_dir = run_dir.join("excel");
15445    let json_dir = run_dir.join("json");
15446    let submodules_dir = run_dir.join("submodules");
15447    for dir in &[
15448        run_dir,
15449        &html_dir,
15450        &pdf_dir,
15451        &excel_dir,
15452        &json_dir,
15453        &submodules_dir,
15454    ] {
15455        fs::create_dir_all(dir)
15456            .with_context(|| format!("failed to create directory {}", dir.display()))?;
15457    }
15458
15459    // HTML report in html/.
15460    let html_path = {
15461        let path = html_dir.join(format!("report_{file_stem}.html"));
15462        fs::write(&path, report_html)
15463            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15464        Some(path)
15465    };
15466
15467    // JSON result in json/.
15468    let json_path = {
15469        let path = json_dir.join(format!("result_{file_stem}.json"));
15470        let json = serde_json::to_string_pretty(run)
15471            .context("failed to serialize analysis run to JSON")?;
15472        fs::write(&path, json)
15473            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15474        Some(path)
15475    };
15476
15477    // PDF in pdf/.
15478    let (pdf_path, pending_pdf) = {
15479        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15480        match write_pdf_from_run(run, &pdf_dest) {
15481            Ok(()) => {
15482                eprintln!(
15483                    "[oxide-sloc][pdf] native PDF written to {}",
15484                    pdf_dest.display()
15485                );
15486                (Some(pdf_dest), None)
15487            }
15488            Err(native_err) => {
15489                eprintln!(
15490                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15491                );
15492                let source_html_path = html_path
15493                    .as_ref()
15494                    .expect("html_path always Some here")
15495                    .clone();
15496                let pending = Some((source_html_path, pdf_dest.clone(), false));
15497                (Some(pdf_dest), pending)
15498            }
15499        }
15500    };
15501
15502    // CSV and XLSX in excel/.
15503    let csv_path = {
15504        let path = excel_dir.join(format!("report_{file_stem}.csv"));
15505        if let Err(e) = sloc_report::write_csv(run, &path) {
15506            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15507            None
15508        } else {
15509            Some(path)
15510        }
15511    };
15512
15513    let xlsx_path = {
15514        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15515        if let Err(e) = sloc_report::write_xlsx(run, &path) {
15516            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15517            None
15518        } else {
15519            Some(path)
15520        }
15521    };
15522
15523    // Scan config in json/.
15524    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15525
15526    // Eagerly generate sub-reports before index.html so relative links work.
15527    if run.effective_configuration.discovery.submodule_breakdown {
15528        let run_id = &run.tool.run_id;
15529        for s in &run.submodule_summaries {
15530            build_submodule_row(s, run, run_id, run_dir);
15531        }
15532    }
15533
15534    // index.html at root — offline static export of the result-page dashboard.
15535    generate_offline_index(
15536        run,
15537        run_dir,
15538        file_stem,
15539        html_path.as_deref(),
15540        pdf_path.as_deref(),
15541        json_path.as_deref(),
15542        scan_config_path.as_deref(),
15543        &result_context,
15544    );
15545
15546    Ok((
15547        RunArtifacts {
15548            output_dir: run_dir.to_path_buf(),
15549            html_path,
15550            pdf_path,
15551            json_path,
15552            csv_path,
15553            xlsx_path,
15554            scan_config_path,
15555            report_title: report_title.to_string(),
15556            result_context,
15557        },
15558        pending_pdf,
15559    ))
15560}
15561
15562/// Render a static offline result-page dashboard and write it as `index.html` at
15563/// the root of the run output directory so business users can open it from disk.
15564#[allow(clippy::too_many_arguments)]
15565#[allow(clippy::too_many_lines)]
15566#[allow(clippy::similar_names)]
15567fn generate_offline_index(
15568    run: &sloc_core::AnalysisRun,
15569    run_dir: &Path,
15570    file_stem: &str,
15571    html_path: Option<&Path>,
15572    pdf_path: Option<&Path>,
15573    json_path: Option<&Path>,
15574    scan_config_path: Option<&Path>,
15575    result_context: &RunResultContext,
15576) {
15577    let prev_entry = &result_context.prev_entry;
15578    let prev_scan_count = result_context.prev_scan_count;
15579    let project_path = &result_context.project_path;
15580
15581    let scan_delta = prev_entry.as_ref().and_then(|prev| {
15582        prev.json_path
15583            .as_ref()
15584            .and_then(|p| read_json(p).ok())
15585            .map(|prev_run| compute_delta(&prev_run, run))
15586    });
15587
15588    let files_analyzed = run.per_file_records.len() as u64;
15589    let files_skipped = run.skipped_file_records.len() as u64;
15590    let totals = sum_lang_totals(run);
15591
15592    let DeltaFields {
15593        prev_fa_str,
15594        prev_fs_str,
15595        prev_pl_str,
15596        prev_cl_str,
15597        prev_cml_str,
15598        prev_bl_str,
15599        delta_fa_str,
15600        delta_fa_class,
15601        delta_fs_str,
15602        delta_fs_class,
15603        delta_pl_str,
15604        delta_pl_class,
15605        delta_cl_str,
15606        delta_cl_class,
15607        delta_cml_str,
15608        delta_cml_class,
15609        delta_bl_str,
15610        delta_bl_class,
15611        delta_lines_added,
15612        delta_lines_removed,
15613        delta_lines_net_str,
15614        delta_lines_net_class,
15615    } = compute_delta_fields(
15616        prev_entry.as_ref(),
15617        &totals,
15618        files_analyzed,
15619        files_skipped,
15620        scan_delta.as_ref(),
15621    );
15622
15623    let git_commit_url = git_commit_url_for(run);
15624    let git_branch_url = git_branch_url_for(run);
15625    let scan_performed_by = scan_performed_by(run);
15626
15627    // Convert absolute path to relative from run_dir (for file:// navigation).
15628    let make_rel = |p: Option<&Path>| -> Option<String> {
15629        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15630            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15631    };
15632
15633    let run_id = &run.tool.run_id;
15634
15635    // Submodule rows with relative paths into submodules/.
15636    let submodule_rows: Vec<SubmoduleRow> = run
15637        .submodule_summaries
15638        .iter()
15639        .map(|s| {
15640            let safe = sanitize_project_label(&s.name);
15641            let key = format!("sub_{safe}");
15642            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15643            SubmoduleRow {
15644                name: s.name.clone(),
15645                relative_path: s.relative_path.clone(),
15646                files_analyzed: s.files_analyzed,
15647                code_lines: s.code_lines,
15648                comment_lines: s.comment_lines,
15649                blank_lines: s.blank_lines,
15650                total_physical_lines: s.total_physical_lines,
15651                html_url: if sub_path.exists() {
15652                    Some(format!("submodules/{key}.html"))
15653                } else {
15654                    None
15655                },
15656            }
15657        })
15658        .collect();
15659
15660    let lang_chart_json = build_lang_chart_json(run);
15661
15662    let scan_config_rel =
15663        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15664
15665    let template = ResultTemplate {
15666        version: env!("CARGO_PKG_VERSION"),
15667        report_title: run.effective_configuration.reporting.report_title.clone(),
15668        project_path: project_path.clone(),
15669        output_dir: display_path(run_dir),
15670        run_id: run_id.clone(),
15671        run_id_short: run_id
15672            .split('-')
15673            .next_back()
15674            .unwrap_or(run_id)
15675            .chars()
15676            .take(7)
15677            .collect(),
15678        files_analyzed,
15679        files_skipped,
15680        physical_lines: totals.physical_lines,
15681        code_lines: totals.code_lines,
15682        comment_lines: totals.comment_lines,
15683        blank_lines: totals.blank_lines,
15684        mixed_lines: totals.mixed_lines,
15685        functions: totals.functions,
15686        classes: totals.classes,
15687        variables: totals.variables,
15688        imports: totals.imports,
15689        html_url: make_rel(html_path),
15690        pdf_url: make_rel(pdf_path),
15691        json_url: make_rel(json_path),
15692        html_download_url: make_rel(html_path),
15693        pdf_download_url: make_rel(pdf_path),
15694        json_download_url: make_rel(json_path),
15695        html_path: html_path.map(display_path),
15696        json_path: json_path.map(display_path),
15697        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15698        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15699        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15700        prev_fa_str,
15701        prev_fs_str,
15702        prev_pl_str,
15703        prev_cl_str,
15704        prev_cml_str,
15705        prev_bl_str,
15706        delta_fa_str,
15707        delta_fa_class,
15708        delta_fs_str,
15709        delta_fs_class,
15710        delta_pl_str,
15711        delta_pl_class,
15712        delta_cl_str,
15713        delta_cl_class,
15714        delta_cml_str,
15715        delta_cml_class,
15716        delta_bl_str,
15717        delta_bl_class,
15718        delta_lines_added,
15719        delta_lines_removed,
15720        delta_lines_net_str,
15721        delta_lines_net_class,
15722        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15723        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15724        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15725        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15726        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15727        git_branch: run.git_branch.clone(),
15728        git_branch_url,
15729        git_commit: run.git_commit_short.clone(),
15730        git_commit_long: run.git_commit_long.clone(),
15731        git_author: run.git_commit_author.clone(),
15732        git_commit_url,
15733        scan_performed_by,
15734        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
15735        os_display: format!(
15736            "{} / {}",
15737            run.environment.operating_system, run.environment.architecture
15738        ),
15739        test_count: run.summary_totals.test_count,
15740        test_assertion_count: run.summary_totals.test_assertion_count,
15741        current_scan_number: prev_scan_count + 1,
15742        prev_scan_count,
15743        submodule_rows,
15744        pdf_generating: false,
15745        scan_config_url: scan_config_rel,
15746        lang_chart_json,
15747        scatter_chart_json: build_scatter_chart_json(run),
15748        semantic_chart_json: build_semantic_chart_json(run),
15749        submodule_chart_json: build_submodule_chart_json(run),
15750        has_submodule_data: !run.submodule_summaries.is_empty(),
15751        has_semantic_data: run
15752            .totals_by_language
15753            .iter()
15754            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
15755        csp_nonce: String::new(),
15756        confluence_configured: false,
15757        server_mode: false,
15758        report_header_footer: run
15759            .effective_configuration
15760            .reporting
15761            .report_header_footer
15762            .clone(),
15763        is_offline: true,
15764        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
15765        lsloc: run.summary_totals.lsloc,
15766        uloc: run.uloc,
15767        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
15768        duplicate_group_count: run.duplicate_groups.len(),
15769        has_cocomo: run.cocomo.is_some(),
15770        cocomo_effort_str: run
15771            .cocomo
15772            .as_ref()
15773            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
15774        cocomo_duration_str: run
15775            .cocomo
15776            .as_ref()
15777            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
15778        cocomo_staff_str: run
15779            .cocomo
15780            .as_ref()
15781            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
15782        cocomo_ksloc_str: run
15783            .cocomo
15784            .as_ref()
15785            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
15786        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
15787            || "Organic".to_string(),
15788            |c| cocomo_mode_label(c.mode).to_string(),
15789        ),
15790        cocomo_mode_tooltip: run
15791            .cocomo
15792            .as_ref()
15793            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
15794        complexity_alert: 0,
15795        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
15796        cov_line_pct: cov_pct_str(
15797            run.summary_totals.coverage_lines_hit,
15798            run.summary_totals.coverage_lines_found,
15799        ),
15800        cov_fn_pct: cov_pct_str(
15801            run.summary_totals.coverage_functions_hit,
15802            run.summary_totals.coverage_functions_found,
15803        ),
15804        cov_branch_pct: cov_pct_str(
15805            run.summary_totals.coverage_branches_hit,
15806            run.summary_totals.coverage_branches_found,
15807        ),
15808        cov_lines_summary: cov_lines_summary_str(
15809            run.summary_totals.coverage_lines_hit,
15810            run.summary_totals.coverage_lines_found,
15811        ),
15812    };
15813
15814    if let Ok(html) = template.render() {
15815        // Inline the brand + watermark logos as data URIs: a file:// page has no
15816        // server to resolve the /images/logo/* routes, so without this the top-left
15817        // logo and the repeated "Oxide" background watermark render as broken images.
15818        let html = inline_offline_logos(&html);
15819        let index_path = run_dir.join("index.html");
15820        if let Err(e) = fs::write(&index_path, html) {
15821            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
15822        }
15823    }
15824}
15825
15826/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
15827/// offline `index.html` displays the brand logo and background watermark when
15828/// opened directly from disk (file://), where the `/images/...` routes do not exist.
15829fn inline_offline_logos(html: &str) -> String {
15830    use base64::Engine;
15831    let text_uri = format!(
15832        "data:image/png;base64,{}",
15833        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
15834    );
15835    let small_uri = format!(
15836        "data:image/png;base64,{}",
15837        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
15838    );
15839    html.replace("/images/logo/logo-text.png", &text_uri)
15840        .replace("/images/logo/small-logo.png", &small_uri)
15841}
15842
15843/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
15844/// then root (old flat layout), for backwards compatibility.
15845fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
15846    // New layout: json/scan-config_*.json
15847    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
15848        return Some(found);
15849    }
15850    // Old flat layout: scan-config.json or scan-config_*.json at root
15851    find_scan_config_in_dir_flat(dir)
15852}
15853
15854fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
15855    let exact = dir.join("scan-config.json");
15856    if exact.exists() {
15857        return Some(exact);
15858    }
15859    fs::read_dir(dir).ok().and_then(|entries| {
15860        entries
15861            .filter_map(std::result::Result::ok)
15862            .find(|e| {
15863                let name = e.file_name();
15864                let name = name.to_string_lossy();
15865                name.starts_with("scan-config") && name.ends_with(".json")
15866            })
15867            .map(|e| e.path())
15868    })
15869}
15870
15871// ── Config export / import ────────────────────────────────────────────────────
15872
15873/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
15874/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
15875#[derive(Deserialize)]
15876struct ExportPdfRequest {
15877    html: String,
15878    #[serde(default)]
15879    filename: Option<String>,
15880}
15881
15882async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
15883    let html_content = body.html;
15884    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
15885    if html_content.is_empty() {
15886        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
15887    }
15888    // Write HTML to a temp file, run headless Chrome PDF export, read result.
15889    let tmp_dir = std::env::temp_dir();
15890    let html_path = tmp_dir.join(format!(
15891        "sloc-export-{}.html",
15892        uuid::Uuid::new_v4().simple()
15893    ));
15894    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
15895    if let Err(e) = std::fs::write(&html_path, &html_content) {
15896        return (
15897            StatusCode::INTERNAL_SERVER_ERROR,
15898            format!("Failed to write temp HTML: {e}"),
15899        )
15900            .into_response();
15901    }
15902    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
15903    let _ = std::fs::remove_file(&html_path);
15904    if let Err(e) = pdf_result {
15905        let _ = std::fs::remove_file(&pdf_path);
15906        return (
15907            StatusCode::INTERNAL_SERVER_ERROR,
15908            format!("PDF generation failed: {e}"),
15909        )
15910            .into_response();
15911    }
15912    let pdf_bytes = match std::fs::read(&pdf_path) {
15913        Ok(b) => b,
15914        Err(e) => {
15915            let _ = std::fs::remove_file(&pdf_path);
15916            return (
15917                StatusCode::INTERNAL_SERVER_ERROR,
15918                format!("Failed to read PDF: {e}"),
15919            )
15920                .into_response();
15921        }
15922    };
15923    let _ = std::fs::remove_file(&pdf_path);
15924    let safe_name: String = filename
15925        .chars()
15926        .map(|c| {
15927            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
15928                c
15929            } else {
15930                '_'
15931            }
15932        })
15933        .collect();
15934    let disposition = format!("attachment; filename=\"{safe_name}\"");
15935    (
15936        [
15937            (header::CONTENT_TYPE, "application/pdf".to_string()),
15938            (header::CONTENT_DISPOSITION, disposition),
15939        ],
15940        pdf_bytes,
15941    )
15942        .into_response()
15943}
15944
15945async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
15946    let toml_str = match toml::to_string_pretty(&state.base_config) {
15947        Ok(s) => s,
15948        Err(e) => {
15949            return (
15950                StatusCode::INTERNAL_SERVER_ERROR,
15951                format!("serialization error: {e}"),
15952            )
15953                .into_response();
15954        }
15955    };
15956    (
15957        [
15958            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
15959            (
15960                header::CONTENT_DISPOSITION,
15961                "attachment; filename=\".oxide-sloc.toml\"",
15962            ),
15963        ],
15964        toml_str,
15965    )
15966        .into_response()
15967}
15968
15969#[derive(Serialize)]
15970struct OkResponse {
15971    ok: bool,
15972}
15973
15974#[derive(Serialize)]
15975struct SaveProfileResponse {
15976    ok: bool,
15977    id: String,
15978}
15979
15980#[derive(Serialize)]
15981struct ProfileListResponse {
15982    profiles: Vec<ScanProfile>,
15983}
15984
15985#[derive(Serialize)]
15986struct ImportConfigResponse {
15987    ok: bool,
15988    config: sloc_config::AppConfig,
15989}
15990
15991#[derive(Deserialize)]
15992struct ImportConfigBody {
15993    toml: String,
15994}
15995
15996async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
15997    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
15998        Ok(config) => {
15999            if let Err(e) = config.validate() {
16000                return error::unprocessable_entity(&e.to_string());
16001            }
16002            Json(ImportConfigResponse { ok: true, config }).into_response()
16003        }
16004        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16005    }
16006}
16007
16008// ── Scan profiles API ─────────────────────────────────────────────────────────
16009
16010async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16011    let store = state.scan_profiles.lock().await;
16012    Json(ProfileListResponse {
16013        profiles: store.profiles.clone(),
16014    })
16015}
16016
16017#[derive(Deserialize)]
16018struct SaveScanProfileBody {
16019    name: String,
16020    params: serde_json::Value,
16021}
16022
16023async fn api_save_scan_profile(
16024    State(state): State<AppState>,
16025    Json(body): Json<SaveScanProfileBody>,
16026) -> impl IntoResponse {
16027    if body.name.trim().is_empty() {
16028        return error::bad_request("name must not be empty");
16029    }
16030
16031    let id = uuid::Uuid::new_v4().to_string();
16032    let profile = ScanProfile {
16033        id: id.clone(),
16034        name: body.name.trim().to_string(),
16035        created_at: chrono::Utc::now().to_rfc3339(),
16036        params: body.params,
16037    };
16038
16039    let mut store = state.scan_profiles.lock().await;
16040    store.profiles.push(profile);
16041    if let Err(e) = store.save(&state.scan_profiles_path) {
16042        tracing::warn!("failed to persist scan profiles: {e}");
16043    }
16044    drop(store);
16045
16046    (
16047        StatusCode::CREATED,
16048        Json(SaveProfileResponse { ok: true, id }),
16049    )
16050        .into_response()
16051}
16052
16053async fn api_delete_scan_profile(
16054    State(state): State<AppState>,
16055    AxumPath(id): AxumPath<String>,
16056) -> impl IntoResponse {
16057    let mut store = state.scan_profiles.lock().await;
16058    let before = store.profiles.len();
16059    store.profiles.retain(|p| p.id != id);
16060    if store.profiles.len() == before {
16061        drop(store);
16062        return error::not_found("profile not found");
16063    }
16064    if let Err(e) = store.save(&state.scan_profiles_path) {
16065        tracing::warn!("failed to persist scan profiles: {e}");
16066    }
16067    drop(store);
16068    Json(OkResponse { ok: true }).into_response()
16069}
16070
16071fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16072    let value = raw.unwrap_or("out/web").trim();
16073    let path = if value.is_empty() {
16074        PathBuf::from("out/web")
16075    } else {
16076        PathBuf::from(value)
16077    };
16078
16079    if path.is_absolute() {
16080        path
16081    } else {
16082        workspace_root().join(path)
16083    }
16084}
16085
16086/// Derive the directory that holds remote-repo clones from the output root.
16087fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16088    std::env::var("SLOC_GIT_CLONES_DIR")
16089        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16090}
16091
16092/// Build a deterministic filesystem path for a cloned remote repository.
16093/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16094pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16095    let safe: String = repo_url
16096        .chars()
16097        .map(|c| {
16098            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16099                c
16100            } else {
16101                '_'
16102            }
16103        })
16104        .take(80)
16105        .collect();
16106    clones_dir.join(safe)
16107}
16108
16109/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16110/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16111pub(crate) fn scan_path_to_artifacts(
16112    scan_path: &Path,
16113    base_config: &AppConfig,
16114    label: &str,
16115) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16116    let mut config = base_config.clone();
16117    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16118    label.clone_into(&mut config.reporting.report_title);
16119    let run = analyze(&config, "git", None, None)?;
16120    let html = render_html(&run)?;
16121    let run_id = run.tool.run_id.clone();
16122    let project_label = sanitize_project_label(label);
16123    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16124    let file_stem = {
16125        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16126        if commit.is_empty() {
16127            project_label
16128        } else {
16129            format!("{project_label}_{commit}")
16130        }
16131    };
16132    let (artifacts, _pending_pdf) = persist_run_artifacts(
16133        &run,
16134        &html,
16135        &output_dir,
16136        label,
16137        &file_stem,
16138        RunResultContext::default(),
16139    )?;
16140    Ok((run_id, artifacts, run))
16141}
16142
16143/// Re-spawn background poll tasks for any polling schedules saved to disk.
16144async fn restart_poll_schedules(state: &AppState) {
16145    let store = state.schedules.lock().await;
16146    let poll_schedules: Vec<_> = store
16147        .schedules
16148        .iter()
16149        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16150        .cloned()
16151        .collect();
16152    drop(store);
16153    for schedule in poll_schedules {
16154        let interval = schedule.interval_secs.unwrap_or(300);
16155        let st = state.clone();
16156        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16157    }
16158}
16159
16160/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16161/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16162/// header (no HMAC over the body), so the token is exposed in cleartext unless
16163/// the transport is encrypted. This is only an advisory — TLS may be terminated
16164/// by an upstream reverse proxy, in which case the warning can be ignored.
16165async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16166    if state.tls_enabled {
16167        return;
16168    }
16169    let store = state.schedules.lock().await;
16170    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16171        s.kind == sloc_git::ScanScheduleKind::Webhook
16172            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16173    });
16174    drop(store);
16175    if has_gitlab_webhook {
16176        tracing::warn!(
16177            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16178             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16179             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16180             proxy so the token is not exposed in cleartext."
16181        );
16182    }
16183}
16184
16185fn split_patterns(raw: Option<&str>) -> Vec<String> {
16186    raw.unwrap_or("")
16187        .lines()
16188        .flat_map(|line| line.split(','))
16189        .map(str::trim)
16190        .filter(|part| !part.is_empty())
16191        .map(ToOwned::to_owned)
16192        .collect()
16193}
16194
16195#[must_use]
16196pub fn build_sub_run(
16197    parent: &AnalysisRun,
16198    sub: &sloc_core::SubmoduleSummary,
16199    parent_path: &str,
16200) -> AnalysisRun {
16201    let sub_files: Vec<_> = parent
16202        .per_file_records
16203        .iter()
16204        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16205        .cloned()
16206        .collect();
16207    let mut config = parent.effective_configuration.clone();
16208    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16209
16210    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16211    let mut functions = 0u64;
16212    let mut classes = 0u64;
16213    let mut variables = 0u64;
16214    let mut imports = 0u64;
16215    let mut test_count = 0u64;
16216    let mut test_assertion_count = 0u64;
16217    let mut test_suite_count = 0u64;
16218    let mut mixed_lines_separate = 0u64;
16219    let mut coverage_lines_found = 0u64;
16220    let mut coverage_lines_hit = 0u64;
16221    let mut coverage_functions_found = 0u64;
16222    let mut coverage_functions_hit = 0u64;
16223    let mut coverage_branches_found = 0u64;
16224    let mut coverage_branches_hit = 0u64;
16225    for r in &sub_files {
16226        functions += r.raw_line_categories.functions;
16227        classes += r.raw_line_categories.classes;
16228        variables += r.raw_line_categories.variables;
16229        imports += r.raw_line_categories.imports;
16230        test_count += r.raw_line_categories.test_count;
16231        test_assertion_count += r.raw_line_categories.test_assertion_count;
16232        test_suite_count += r.raw_line_categories.test_suite_count;
16233        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16234        if let Some(cov) = &r.coverage {
16235            coverage_lines_found += u64::from(cov.lines_found);
16236            coverage_lines_hit += u64::from(cov.lines_hit);
16237            coverage_functions_found += u64::from(cov.functions_found);
16238            coverage_functions_hit += u64::from(cov.functions_hit);
16239            coverage_branches_found += u64::from(cov.branches_found);
16240            coverage_branches_hit += u64::from(cov.branches_hit);
16241        }
16242    }
16243
16244    AnalysisRun {
16245        tool: parent.tool.clone(),
16246        environment: parent.environment.clone(),
16247        effective_configuration: config,
16248        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16249        summary_totals: SummaryTotals {
16250            files_considered: sub.files_analyzed,
16251            files_analyzed: sub.files_analyzed,
16252            files_skipped: 0,
16253            total_physical_lines: sub.total_physical_lines,
16254            code_lines: sub.code_lines,
16255            comment_lines: sub.comment_lines,
16256            blank_lines: sub.blank_lines,
16257            mixed_lines_separate,
16258            functions,
16259            classes,
16260            variables,
16261            imports,
16262            test_count,
16263            test_assertion_count,
16264            test_suite_count,
16265            coverage_lines_found,
16266            coverage_lines_hit,
16267            coverage_functions_found,
16268            coverage_functions_hit,
16269            coverage_branches_found,
16270            coverage_branches_hit,
16271            cyclomatic_complexity: 0,
16272            lsloc: None,
16273        },
16274        totals_by_language: sub.language_summaries.clone(),
16275        per_file_records: sub_files,
16276        skipped_file_records: vec![],
16277        warnings: vec![],
16278        submodule_summaries: vec![],
16279        git_commit_short: sub.git_commit_short.clone(),
16280        git_commit_long: sub.git_commit_long.clone(),
16281        git_branch: sub.git_branch.clone(),
16282        git_commit_author: sub.git_commit_author.clone(),
16283        git_commit_date: sub.git_commit_date.clone(),
16284        git_tags: None,
16285        git_nearest_tag: None,
16286        git_remote_url: sub.git_remote_url.clone(),
16287        style_summary: None,
16288        cocomo: None,
16289        uloc: 0,
16290        dryness_pct: None,
16291        duplicate_groups: vec![],
16292        duplicates_excluded: 0,
16293    }
16294}
16295
16296#[must_use]
16297pub fn sanitize_project_label(raw: &str) -> String {
16298    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16299    // where `Path` treats '\' as a literal character, not a separator.
16300    let candidate = raw
16301        .split(['/', '\\'])
16302        .rfind(|s| !s.is_empty())
16303        .unwrap_or("project");
16304
16305    let mut value = String::with_capacity(candidate.len());
16306    for ch in candidate.chars() {
16307        if ch.is_ascii_alphanumeric() {
16308            value.push(ch.to_ascii_lowercase());
16309        } else {
16310            value.push('-');
16311        }
16312    }
16313
16314    let compact = value.trim_matches('-').to_string();
16315    if compact.is_empty() {
16316        "project".to_string()
16317    } else {
16318        compact
16319    }
16320}
16321
16322/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16323/// comparisons with non-canonicalized stored paths work correctly.
16324fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16325    let s = path.to_string_lossy();
16326    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16327        return PathBuf::from(format!(r"\\{rest}"));
16328    }
16329    if let Some(rest) = s.strip_prefix(r"\\?\") {
16330        return PathBuf::from(rest);
16331    }
16332    path
16333}
16334
16335/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16336/// commit page URL for the most common hosting platforms.
16337fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16338    let base = if let Some(rest) = remote.strip_prefix("git@") {
16339        let (host, path) = rest.split_once(':')?;
16340        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16341    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16342        remote
16343            .trim_end_matches('/')
16344            .trim_end_matches(".git")
16345            .to_owned()
16346    } else {
16347        return None;
16348    };
16349    let base = base.trim_end_matches('/');
16350    // GitLab uses /-/commit/; everything else uses /commit/
16351    if base.contains("gitlab.com") || base.contains("gitlab.") {
16352        Some(format!("{base}/-/commit/{sha}"))
16353    } else if base.contains("bitbucket.org") {
16354        Some(format!("{base}/commits/{sha}"))
16355    } else {
16356        Some(format!("{base}/commit/{sha}"))
16357    }
16358}
16359
16360/// Convert a git remote URL (https or git@) + branch name into a browser-openable
16361/// branch page URL for the most common hosting platforms.
16362fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16363    let base = if let Some(rest) = remote.strip_prefix("git@") {
16364        let (host, path) = rest.split_once(':')?;
16365        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16366    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16367        remote
16368            .trim_end_matches('/')
16369            .trim_end_matches(".git")
16370            .to_owned()
16371    } else {
16372        return None;
16373    };
16374    let base = base.trim_end_matches('/');
16375    if base.contains("gitlab.com") || base.contains("gitlab.") {
16376        Some(format!("{base}/-/tree/{branch}"))
16377    } else {
16378        Some(format!("{base}/tree/{branch}"))
16379    }
16380}
16381
16382fn display_path(path: &Path) -> String {
16383    let s = path.to_string_lossy();
16384    // Strip Windows extended-length prefix for display only; the underlying
16385    // PathBuf remains unchanged so file operations are unaffected.
16386    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
16387    // \\?\C:\path           →  C:\path          (local drive)
16388    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16389        return format!(r"\\{rest}");
16390    }
16391    if let Some(rest) = s.strip_prefix(r"\\?\") {
16392        return rest.to_owned();
16393    }
16394    s.into_owned()
16395}
16396
16397fn sanitize_path_str(s: &str) -> String {
16398    // Forward-slash variants of the Windows extended-length prefix that appear
16399    // when paths stored as plain strings have been processed through some path
16400    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
16401    if let Some(rest) = s.strip_prefix("//?/UNC/") {
16402        return format!("//{rest}");
16403    }
16404    if let Some(rest) = s.strip_prefix("//?/") {
16405        return rest.to_owned();
16406    }
16407    display_path(Path::new(s))
16408}
16409
16410fn workspace_root() -> PathBuf {
16411    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
16412    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16413        let p = PathBuf::from(root);
16414        if p.is_dir() {
16415            return p;
16416        }
16417    }
16418
16419    // Current working directory — works for `cargo run` from the project root
16420    // and for scripts/run.sh which cds there first.
16421    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16422}
16423
16424/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
16425fn make_git_label(repo: &str, ref_name: &str) -> String {
16426    if repo.is_empty() || ref_name.is_empty() {
16427        return String::new();
16428    }
16429    let base = repo
16430        .trim_end_matches('/')
16431        .trim_end_matches(".git")
16432        .rsplit('/')
16433        .next()
16434        .unwrap_or("repo");
16435    let ref_safe: String = ref_name
16436        .chars()
16437        .map(|c| {
16438            if c.is_alphanumeric() || c == '-' || c == '.' {
16439                c
16440            } else {
16441                '_'
16442            }
16443        })
16444        .collect();
16445    format!("{base}_at_{ref_safe}_sloc")
16446}
16447
16448/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
16449fn desktop_dir() -> PathBuf {
16450    if let Ok(profile) = std::env::var("USERPROFILE") {
16451        let p = PathBuf::from(profile).join("Desktop");
16452        if p.exists() {
16453            return p;
16454        }
16455    }
16456    if let Ok(home) = std::env::var("HOME") {
16457        let p = PathBuf::from(home).join("Desktop");
16458        if p.exists() {
16459            return p;
16460        }
16461    }
16462    workspace_root().join("out").join("web")
16463}
16464
16465fn resolve_input_path(raw: &str) -> PathBuf {
16466    let trimmed = raw.trim();
16467    if trimmed.is_empty() {
16468        return workspace_root().join("samples").join("basic");
16469    }
16470
16471    let candidate = PathBuf::from(trimmed);
16472    let resolved = if candidate.is_absolute() {
16473        candidate
16474    } else {
16475        let rooted = workspace_root().join(&candidate);
16476        if rooted.exists() {
16477            rooted
16478        } else {
16479            workspace_root().join(candidate)
16480        }
16481    };
16482
16483    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
16484    // strip that prefix so stored paths and the displayed "Project path" are clean.
16485    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16486    PathBuf::from(display_path(&canonical))
16487}
16488
16489fn dir_size_bytes(path: &Path) -> u64 {
16490    let mut total = 0u64;
16491    if let Ok(rd) = fs::read_dir(path) {
16492        for entry in rd.filter_map(Result::ok) {
16493            let p = entry.path();
16494            if p.is_file() {
16495                if let Ok(meta) = p.metadata() {
16496                    total += meta.len();
16497                }
16498            } else if p.is_dir() {
16499                total += dir_size_bytes(&p);
16500            }
16501        }
16502    }
16503    total
16504}
16505
16506#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
16507fn format_dir_size(bytes: u64) -> String {
16508    if bytes >= 1_073_741_824 {
16509        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16510    } else if bytes >= 1_048_576 {
16511        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16512    } else if bytes >= 1_024 {
16513        format!("{:.0} KB", bytes as f64 / 1_024.0)
16514    } else {
16515        format!("{bytes} B")
16516    }
16517}
16518
16519fn render_submodule_chips(
16520    root: &Path,
16521    submodules: &[(String, std::path::PathBuf)],
16522    out: &mut String,
16523) {
16524    use std::fmt::Write as _;
16525    let count = submodules.len();
16526    out.push_str(r#"<div class="submodule-preview-strip">"#);
16527    write!(
16528        out,
16529        r#"<div class="submodule-preview-label"><svg viewBox="0 0 24 24" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/><circle cx="6" cy="6" r="3"/></svg><strong>{count}</strong>&nbsp;git&nbsp;submodule{}&nbsp;detected</div>"#,
16530        if count == 1 { "" } else { "s" }
16531    )
16532    .ok();
16533    out.push_str(r#"<div class="submodule-preview-chips">"#);
16534    for (sub_name, sub_rel_path) in submodules {
16535        let sub_abs = root.join(sub_rel_path);
16536        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16537        let mut sub_stats = PreviewStats::default();
16538        let mut sub_rows: Vec<PreviewRow> = Vec::new();
16539        let mut sub_langs: Vec<&'static str> = Vec::new();
16540        let mut sub_budget = PreviewBudget {
16541            shown: 0,
16542            max_entries: 2000,
16543            max_depth: 9,
16544        };
16545        let mut sub_next_id = 1usize;
16546        let _ = collect_preview_rows(
16547            &sub_abs,
16548            &sub_abs,
16549            0,
16550            None,
16551            &mut sub_next_id,
16552            &mut sub_budget,
16553            &mut sub_stats,
16554            &mut sub_rows,
16555            &mut sub_langs,
16556            &[],
16557            &[],
16558        );
16559        let stats_json = format!(
16560            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16561            sub_stats.directories,
16562            sub_stats.files,
16563            sub_stats.supported,
16564            sub_stats.skipped,
16565            sub_stats.unsupported
16566        );
16567        write!(
16568            out,
16569            r#"<button type="button" class="submodule-preview-chip" data-sub-name="{}" data-sub-path="{}" data-size="{}" data-sub-stats="{}">{}<span class="submodule-chip-tooltip">Size: {}</span></button>"#,
16570            escape_html(sub_name),
16571            escape_html(&sub_rel_path.to_string_lossy()),
16572            escape_html(&sub_size),
16573            escape_html(&stats_json),
16574            escape_html(sub_name),
16575            escape_html(&sub_size),
16576        )
16577        .ok();
16578    }
16579    out.push_str(
16580        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
16581    );
16582    out.push_str(r"</div>");
16583}
16584
16585/// Amber caution banner shown when the selected folder spans multiple independent
16586/// git repositories. Each repo is a one-click button that re-selects it as the
16587/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
16588fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
16589    use std::fmt::Write as _;
16590    const MAX_LISTED: usize = 5;
16591    let total = layout.nested_repos.len();
16592
16593    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
16594    if layout.root_is_repo {
16595        write!(
16596            out,
16597            r#"<strong>Nested repositories detected</strong><p>This repository contains {total} nested git {} that are not registered submodules. Their files will be counted as part of this project. Submodules are fine — but if these are unrelated repositories, scan one repository at a time. Pick a repository to scan on its own:</p>"#,
16598            if total == 1 { "repository" } else { "repositories" }
16599        )
16600        .ok();
16601    } else {
16602        write!(
16603            out,
16604            r#"<strong>Multiple repositories detected</strong><p>This folder contains {total} independent git repositories. oxide-sloc analyzes one repository at a time — git metrics and totals are only meaningful when the root is a single repository (submodules are fine). Pick one repository as the scan root:</p>"#
16605        )
16606        .ok();
16607    }
16608
16609    out.push_str(r#"<div class="repo-pick-row">"#);
16610    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
16611        let abs = root.join(rel);
16612        let abs_display = display_path(&abs);
16613        let label = rel.to_string_lossy().replace('\\', "/");
16614        write!(
16615            out,
16616            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
16617            escape_html(&abs_display),
16618            escape_html(&label)
16619        )
16620        .ok();
16621    }
16622    if total > MAX_LISTED {
16623        write!(
16624            out,
16625            r#"<span class="repo-pick-more">and {} more</span>"#,
16626            total - MAX_LISTED
16627        )
16628        .ok();
16629    }
16630    out.push_str(r"</div>");
16631
16632    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
16633    out.push_str(r"</div>");
16634}
16635
16636fn render_language_pills_row(languages: &[&str], out: &mut String) {
16637    use std::fmt::Write as _;
16638    if languages.is_empty() {
16639        out.push_str(
16640            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16641        );
16642        return;
16643    }
16644    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16645    for language in languages {
16646        if let Some(icon) = language_icon_file(language) {
16647            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();
16648        } else if let Some(svg) = language_inline_svg(language) {
16649            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();
16650        } else {
16651            write!(
16652                out,
16653                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16654                escape_html(&language.to_ascii_lowercase()),
16655                escape_html(language)
16656            )
16657            .ok();
16658        }
16659    }
16660}
16661
16662#[allow(clippy::too_many_lines)]
16663fn build_preview_html(
16664    root: &Path,
16665    include_patterns: &[String],
16666    exclude_patterns: &[String],
16667) -> Result<String> {
16668    if !root.exists() {
16669        return Ok(format!(
16670            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16671            escape_html(&display_path(root))
16672        ));
16673    }
16674
16675    let _selected = display_path(root);
16676    let mut stats = PreviewStats::default();
16677    let mut rows = Vec::new();
16678    let mut languages = Vec::new();
16679    let mut budget = PreviewBudget {
16680        shown: 0,
16681        max_entries: 600,
16682        max_depth: 9,
16683    };
16684    let mut next_row_id = 1usize;
16685
16686    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16687        || root.to_string_lossy().into_owned(),
16688        std::string::ToString::to_string,
16689    );
16690    let root_modified = root
16691        .metadata()
16692        .ok()
16693        .and_then(|meta| meta.modified().ok())
16694        .map_or_else(|| "-".to_string(), format_system_time);
16695
16696    rows.push(PreviewRow {
16697        row_id: 0,
16698        parent_row_id: None,
16699        depth: 0,
16700        name: format!("{root_name}/"),
16701        kind: PreviewKind::Dir,
16702        is_dir: true,
16703        language: None,
16704        modified: root_modified,
16705        type_label: "Directory".to_string(),
16706    });
16707    collect_preview_rows(
16708        root,
16709        root,
16710        0,
16711        Some(0),
16712        &mut next_row_id,
16713        &mut budget,
16714        &mut stats,
16715        &mut rows,
16716        &mut languages,
16717        include_patterns,
16718        exclude_patterns,
16719    )?;
16720
16721    let root_size = format_dir_size(dir_size_bytes(root));
16722
16723    let mut out = String::new();
16724    write!(
16725        out,
16726        r#"<div class="explorer-wrap" data-project-size="{}">"#,
16727        escape_html(&root_size)
16728    )
16729    .ok();
16730    out.push_str(r#"<div class="explorer-toolbar compact">"#);
16731    out.push_str(r#"<div class="explorer-title-group">"#);
16732    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16733    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16734    out.push_str(r"</div></div>");
16735
16736    out.push_str(r#"<div class="scope-stats">"#);
16737    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();
16738    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();
16739    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();
16740    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();
16741    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();
16742    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>"#);
16743    out.push_str(r"</div>");
16744
16745    let submodules = sloc_core::detect_submodules(root);
16746    if !submodules.is_empty() {
16747        render_submodule_chips(root, &submodules, &mut out);
16748    }
16749
16750    let repo_layout = sloc_core::detect_repository_layout(root);
16751    if repo_layout.has_multiple_repos() {
16752        render_multi_repo_warning(root, &repo_layout, &mut out);
16753    }
16754
16755    out.push_str(r#"<div class="scope-info-row">"#);
16756    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16757    render_language_pills_row(&languages, &mut out);
16758    out.push_str(r"</div></div>");
16759    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>"#);
16760    out.push_str(r"</div>");
16761
16762    out.push_str(r#"<div class="file-explorer-shell">"#);
16763    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>"#);
16764    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>"#);
16765    out.push_str(r#"<div class="file-explorer-tree">"#);
16766    for row in rows {
16767        let status_label = row.kind.label();
16768        let lang_attr = row.language.unwrap_or("");
16769        let toggle_html = if row.is_dir {
16770            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16771                .to_string()
16772        } else {
16773            r#"<span class="tree-bullet">•</span>"#.to_string()
16774        };
16775        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();
16776    }
16777    if budget.shown >= budget.max_entries {
16778        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>"#);
16779    }
16780    out.push_str(r"</div></div></div>");
16781
16782    Ok(out)
16783}
16784
16785#[derive(Default)]
16786struct PreviewStats {
16787    directories: usize,
16788    files: usize,
16789    supported: usize,
16790    skipped: usize,
16791    unsupported: usize,
16792}
16793
16794struct PreviewRow {
16795    row_id: usize,
16796    parent_row_id: Option<usize>,
16797    depth: usize,
16798    name: String,
16799    kind: PreviewKind,
16800    is_dir: bool,
16801    language: Option<&'static str>,
16802    modified: String,
16803    type_label: String,
16804}
16805
16806#[derive(Copy, Clone)]
16807enum PreviewKind {
16808    Dir,
16809    Supported,
16810    Skipped,
16811    Unsupported,
16812}
16813
16814impl PreviewKind {
16815    const fn filter_key(self) -> &'static str {
16816        match self {
16817            Self::Dir => "dir",
16818            Self::Supported => "supported",
16819            Self::Skipped => "skipped",
16820            Self::Unsupported => "unsupported",
16821        }
16822    }
16823
16824    const fn label(self) -> &'static str {
16825        match self {
16826            Self::Dir => "dir",
16827            Self::Supported => "supported",
16828            Self::Skipped => "skipped by policy",
16829            Self::Unsupported => "unsupported",
16830        }
16831    }
16832
16833    const fn badge_class(self) -> &'static str {
16834        match self {
16835            Self::Dir => "badge badge-dir",
16836            Self::Supported => "badge badge-scan",
16837            Self::Skipped => "badge badge-skip",
16838            Self::Unsupported => "badge badge-unsupported",
16839        }
16840    }
16841
16842    const fn node_class(self) -> &'static str {
16843        match self {
16844            Self::Dir => "tree-node-dir",
16845            Self::Supported => "tree-node-supported",
16846            Self::Skipped => "tree-node-skipped",
16847            Self::Unsupported => "tree-node-unsupported",
16848        }
16849    }
16850}
16851
16852struct PreviewBudget {
16853    shown: usize,
16854    max_entries: usize,
16855    max_depth: usize,
16856}
16857
16858/// Handle a single directory entry inside `collect_preview_rows`.
16859/// Returns `true` when the entry was handled (caller should `continue`).
16860#[allow(clippy::too_many_arguments)]
16861fn handle_preview_dir_entry(
16862    root: &Path,
16863    path: &Path,
16864    name: &str,
16865    modified: String,
16866    depth: usize,
16867    parent_row_id: Option<usize>,
16868    row_id: usize,
16869    next_row_id: &mut usize,
16870    budget: &mut PreviewBudget,
16871    stats: &mut PreviewStats,
16872    rows: &mut Vec<PreviewRow>,
16873    languages: &mut Vec<&'static str>,
16874    include_patterns: &[String],
16875    exclude_patterns: &[String],
16876) -> Result<()> {
16877    let relative = preview_relative_path(root, path);
16878    if should_skip_preview_directory(&relative, exclude_patterns) {
16879        return Ok(());
16880    }
16881    stats.directories += 1;
16882    rows.push(PreviewRow {
16883        row_id,
16884        parent_row_id,
16885        depth: depth + 1,
16886        name: format!("{name}/"),
16887        kind: PreviewKind::Dir,
16888        is_dir: true,
16889        language: None,
16890        modified,
16891        type_label: "Directory".to_string(),
16892    });
16893    budget.shown += 1;
16894    if !matches!(name, ".git" | "node_modules" | "target") {
16895        collect_preview_rows(
16896            root,
16897            path,
16898            depth + 1,
16899            Some(row_id),
16900            next_row_id,
16901            budget,
16902            stats,
16903            rows,
16904            languages,
16905            include_patterns,
16906            exclude_patterns,
16907        )?;
16908    }
16909    Ok(())
16910}
16911
16912/// Handle a single file entry inside `collect_preview_rows`.
16913#[allow(clippy::too_many_arguments)]
16914fn handle_preview_file_entry(
16915    root: &Path,
16916    path: &Path,
16917    name: &str,
16918    modified: String,
16919    depth: usize,
16920    parent_row_id: Option<usize>,
16921    row_id: usize,
16922    budget: &mut PreviewBudget,
16923    stats: &mut PreviewStats,
16924    rows: &mut Vec<PreviewRow>,
16925    languages: &mut Vec<&'static str>,
16926    include_patterns: &[String],
16927    exclude_patterns: &[String],
16928) {
16929    let relative = preview_relative_path(root, path);
16930    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16931        return;
16932    }
16933    stats.files += 1;
16934    let kind = classify_preview_file(name);
16935    match kind {
16936        PreviewKind::Supported => stats.supported += 1,
16937        PreviewKind::Skipped => stats.skipped += 1,
16938        PreviewKind::Unsupported => stats.unsupported += 1,
16939        PreviewKind::Dir => {}
16940    }
16941    let language = detect_language_name(name);
16942    if let Some(lang) = language {
16943        if !languages.contains(&lang) {
16944            languages.push(lang);
16945        }
16946    }
16947    rows.push(PreviewRow {
16948        row_id,
16949        parent_row_id,
16950        depth: depth + 1,
16951        name: name.to_owned(),
16952        kind,
16953        is_dir: false,
16954        language,
16955        modified,
16956        type_label: preview_type_label(name, language, kind),
16957    });
16958    budget.shown += 1;
16959}
16960
16961#[allow(clippy::too_many_arguments)]
16962#[allow(clippy::too_many_lines)]
16963fn collect_preview_rows(
16964    root: &Path,
16965    dir: &Path,
16966    depth: usize,
16967    parent_row_id: Option<usize>,
16968    next_row_id: &mut usize,
16969    budget: &mut PreviewBudget,
16970    stats: &mut PreviewStats,
16971    rows: &mut Vec<PreviewRow>,
16972    languages: &mut Vec<&'static str>,
16973    include_patterns: &[String],
16974    exclude_patterns: &[String],
16975) -> Result<()> {
16976    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16977        return Ok(());
16978    }
16979
16980    let mut entries = fs::read_dir(dir)
16981        .with_context(|| format!("failed to read directory {}", dir.display()))?
16982        .filter_map(std::result::Result::ok)
16983        .collect::<Vec<_>>();
16984    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16985
16986    for entry in entries {
16987        if budget.shown >= budget.max_entries {
16988            break;
16989        }
16990
16991        let path = entry.path();
16992        let name = entry.file_name().to_string_lossy().into_owned();
16993        let Ok(metadata) = entry.metadata() else {
16994            continue;
16995        };
16996        let row_id = *next_row_id;
16997        *next_row_id += 1;
16998        let modified = metadata
16999            .modified()
17000            .ok()
17001            .map_or_else(|| "-".to_string(), format_system_time);
17002
17003        if metadata.is_dir() {
17004            handle_preview_dir_entry(
17005                root,
17006                &path,
17007                &name,
17008                modified,
17009                depth,
17010                parent_row_id,
17011                row_id,
17012                next_row_id,
17013                budget,
17014                stats,
17015                rows,
17016                languages,
17017                include_patterns,
17018                exclude_patterns,
17019            )?;
17020            continue;
17021        }
17022
17023        if metadata.is_file() {
17024            handle_preview_file_entry(
17025                root,
17026                &path,
17027                &name,
17028                modified,
17029                depth,
17030                parent_row_id,
17031                row_id,
17032                budget,
17033                stats,
17034                rows,
17035                languages,
17036                include_patterns,
17037                exclude_patterns,
17038            );
17039        }
17040    }
17041
17042    Ok(())
17043}
17044
17045fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17046    if let Some(language) = language {
17047        return format!("{language} source");
17048    }
17049    let lower = name.to_ascii_lowercase();
17050    let ext = Path::new(&lower)
17051        .extension()
17052        .and_then(|e| e.to_str())
17053        .unwrap_or("");
17054    match kind {
17055        PreviewKind::Skipped => {
17056            if lower.ends_with(".min.js") {
17057                "Minified asset".to_string()
17058            } else if [
17059                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17060            ]
17061            .contains(&ext)
17062            {
17063                "Binary or archive".to_string()
17064            } else {
17065                "Skipped file".to_string()
17066            }
17067        }
17068        PreviewKind::Unsupported => {
17069            if ext.is_empty() {
17070                "Unsupported file".to_string()
17071            } else {
17072                format!("{} file", ext.to_ascii_uppercase())
17073            }
17074        }
17075        PreviewKind::Supported => "Supported source".to_string(),
17076        PreviewKind::Dir => "Directory".to_string(),
17077    }
17078}
17079
17080fn format_system_time(time: SystemTime) -> String {
17081    #[allow(clippy::cast_possible_wrap)]
17082    let secs = match time.duration_since(UNIX_EPOCH) {
17083        Ok(duration) => duration.as_secs() as i64,
17084        Err(_) => return "-".to_string(),
17085    };
17086    let days = secs.div_euclid(86_400);
17087    let secs_of_day = secs.rem_euclid(86_400);
17088    let (year, month, day) = civil_from_days(days);
17089    let hour = secs_of_day / 3_600;
17090    let minute = (secs_of_day % 3_600) / 60;
17091    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17092}
17093
17094#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17095fn civil_from_days(days: i64) -> (i32, u32, u32) {
17096    let z = days + 719_468;
17097    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17098    let doe = z - era * 146_097;
17099    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17100    let y = yoe + era * 400;
17101    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17102    let mp = (5 * doy + 2) / 153;
17103    let d = doy - (153 * mp + 2) / 5 + 1;
17104    let m = mp + if mp < 10 { 3 } else { -9 };
17105    let year = y + i64::from(m <= 2);
17106    (year as i32, m as u32, d as u32)
17107}
17108
17109// The input is already lowercased via `to_ascii_lowercase()` before calling
17110// `ends_with`, so the comparisons are inherently case-insensitive.
17111#[allow(clippy::case_sensitive_file_extension_comparisons)]
17112fn detect_language_name(name: &str) -> Option<&'static str> {
17113    let lower = name.to_ascii_lowercase();
17114    if lower.ends_with(".c") || lower.ends_with(".h") {
17115        Some("C")
17116    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17117        .iter()
17118        .any(|s| lower.ends_with(s))
17119    {
17120        Some("C++")
17121    } else if lower.ends_with(".cs") {
17122        Some("C#")
17123    } else if lower.ends_with(".py") {
17124        Some("Python")
17125    } else if lower.ends_with(".sh") {
17126        Some("Shell")
17127    } else if [".ps1", ".psm1", ".psd1"]
17128        .iter()
17129        .any(|s| lower.ends_with(s))
17130    {
17131        Some("PowerShell")
17132    } else {
17133        None
17134    }
17135}
17136
17137fn language_icon_file(language: &str) -> Option<&'static str> {
17138    match language {
17139        "C" => Some("c.png"),
17140        "C++" => Some("cpp.png"),
17141        "C#" => Some("c-sharp.png"),
17142        "Python" => Some("python.png"),
17143        "Shell" => Some("shell.png"),
17144        "PowerShell" => Some("powershell.png"),
17145        "JavaScript" => Some("java-script.png"),
17146        "HTML" => Some("html-5.png"),
17147        "Java" => Some("java.png"),
17148        "Visual Basic" => Some("visual-basic.png"),
17149        "Assembly" => Some("asm.png"),
17150        "Go" => Some("go.png"),
17151        "R" => Some("r.png"),
17152        "XML" => Some("xml.png"),
17153        "Groovy" => Some("groovy.png"),
17154        "Dockerfile" => Some("docker.png"),
17155        "Makefile" => Some("makefile.svg"),
17156        "Perl" => Some("perl.svg"),
17157        _ => None,
17158    }
17159}
17160
17161// Inline SVG badges for languages that have no PNG icon in images/icons/.
17162// Using inline SVG keeps the web UI fully self-contained — no extra files
17163// needed on disk, no 404s on air-gapped deployments.
17164// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17165fn language_inline_svg(language: &str) -> Option<&'static str> {
17166    match language {
17167        "Rust" => Some(
17168            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>"##,
17169        ),
17170        "TypeScript" => Some(
17171            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>"##,
17172        ),
17173        _ => None,
17174    }
17175}
17176
17177// The input is already lowercased via `to_ascii_lowercase()` before the
17178// `ends_with` calls, so these comparisons are inherently case-insensitive.
17179#[allow(clippy::case_sensitive_file_extension_comparisons)]
17180fn classify_preview_file(name: &str) -> PreviewKind {
17181    let lower = name.to_ascii_lowercase();
17182
17183    let scannable = [
17184        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17185        ".psm1", ".psd1",
17186    ]
17187    .iter()
17188    .any(|suffix| lower.ends_with(suffix));
17189
17190    if scannable {
17191        PreviewKind::Supported
17192    } else if lower.ends_with(".min.js")
17193        || lower.ends_with(".lock")
17194        || lower.ends_with(".png")
17195        || lower.ends_with(".jpg")
17196        || lower.ends_with(".jpeg")
17197        || lower.ends_with(".gif")
17198        || lower.ends_with(".zip")
17199        || lower.ends_with(".pdf")
17200        || lower.ends_with(".pyc")
17201        || lower.ends_with(".xz")
17202        || lower.ends_with(".tar")
17203        || lower.ends_with(".gz")
17204    {
17205        PreviewKind::Skipped
17206    } else {
17207        PreviewKind::Unsupported
17208    }
17209}
17210
17211fn preview_relative_path(root: &Path, path: &Path) -> String {
17212    path.strip_prefix(root)
17213        .ok()
17214        .unwrap_or(path)
17215        .to_string_lossy()
17216        .replace('\\', "/")
17217        .trim_matches('/')
17218        .to_string()
17219}
17220
17221fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17222    if relative.is_empty() {
17223        return false;
17224    }
17225
17226    exclude_patterns.iter().any(|pattern| {
17227        wildcard_match(pattern, relative)
17228            || wildcard_match(pattern, &format!("{relative}/"))
17229            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17230    })
17231}
17232
17233fn should_include_preview_file(
17234    relative: &str,
17235    include_patterns: &[String],
17236    exclude_patterns: &[String],
17237) -> bool {
17238    if relative.is_empty() {
17239        return true;
17240    }
17241
17242    let included = include_patterns.is_empty()
17243        || include_patterns
17244            .iter()
17245            .any(|pattern| wildcard_match(pattern, relative));
17246    let excluded = exclude_patterns
17247        .iter()
17248        .any(|pattern| wildcard_match(pattern, relative));
17249
17250    included && !excluded
17251}
17252
17253fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17254    let pattern = pattern.trim().replace('\\', "/");
17255    let candidate = candidate.trim().replace('\\', "/");
17256    let p = pattern.as_bytes();
17257    let c = candidate.as_bytes();
17258    let mut pi = 0usize;
17259    let mut ci = 0usize;
17260    let mut star: Option<usize> = None;
17261    let mut star_match = 0usize;
17262
17263    while ci < c.len() {
17264        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17265            pi += 1;
17266            ci += 1;
17267        } else if pi < p.len() && p[pi] == b'*' {
17268            while pi < p.len() && p[pi] == b'*' {
17269                pi += 1;
17270            }
17271            star = Some(pi);
17272            star_match = ci;
17273        } else if let Some(star_pi) = star {
17274            star_match += 1;
17275            ci = star_match;
17276            pi = star_pi;
17277        } else {
17278            return false;
17279        }
17280    }
17281
17282    while pi < p.len() && p[pi] == b'*' {
17283        pi += 1;
17284    }
17285
17286    pi == p.len()
17287}
17288
17289fn escape_html(value: &str) -> String {
17290    value
17291        .replace('&', "&amp;")
17292        .replace('<', "&lt;")
17293        .replace('>', "&gt;")
17294        .replace('"', "&quot;")
17295        .replace('\'', "&#39;")
17296}
17297
17298#[derive(Clone)]
17299struct SubmoduleRow {
17300    name: String,
17301    relative_path: String,
17302    files_analyzed: u64,
17303    code_lines: u64,
17304    comment_lines: u64,
17305    blank_lines: u64,
17306    total_physical_lines: u64,
17307    html_url: Option<String>,
17308}
17309
17310#[derive(Template)]
17311#[template(
17312    source = r##"
17313<!doctype html>
17314<html lang="en">
17315<head>
17316  <meta charset="utf-8">
17317  <title>OxideSLOC | tmp-sloc</title>
17318  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17319  <style nonce="{{ csp_nonce }}">
17320    :root {
17321      --bg: #efe9e2;
17322      --surface: #fcfaf7;
17323      --surface-2: #f7f0e8;
17324      --surface-3: #efe3d5;
17325      --line: #dfcfbf;
17326      --line-strong: #cfb29c;
17327      --text: #2f241c;
17328      --muted: #6f6257;
17329      --muted-2: #917f71;
17330      --nav: #b85d33;
17331      --nav-2: #7a371b;
17332      --accent: #2563eb;
17333      --accent-2: #1d4ed8;
17334      --oxide: #b85d33;
17335      --oxide-2: #8f4220;
17336      --success-bg: #eaf9ee;
17337      --success-text: #1c8746;
17338      --warn-bg: #fff2d8;
17339      --warn-text: #926000;
17340      --danger-bg: #fdeaea;
17341      --danger-text: #b33b3b;
17342      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17343      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17344      --radius: 14px;
17345    }
17346
17347    body.dark-theme {
17348      --bg: #1b1511;
17349      --surface: #261c17;
17350      --surface-2: #2d221d;
17351      --surface-3: #372922;
17352      --line: #524238;
17353      --line-strong: #6c5649;
17354      --text: #f5ece6;
17355      --muted: #c7b7aa;
17356      --muted-2: #aa9485;
17357      --nav: #b85d33;
17358      --nav-2: #7a371b;
17359      --accent: #6f9bff;
17360      --accent-2: #4a78ee;
17361      --oxide: #d37a4c;
17362      --oxide-2: #b35428;
17363      --success-bg: #163927;
17364      --success-text: #8fe2a8;
17365      --warn-bg: #3c2d11;
17366      --warn-text: #f3cb75;
17367      --danger-bg: #3d1f1f;
17368      --danger-text: #ff9f9f;
17369      --shadow: 0 14px 28px rgba(0,0,0,0.28);
17370      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17371    }
17372
17373    * { box-sizing: border-box; }
17374    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); }
17375    html { overflow-y: scroll; }
17376    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17377    .top-nav, .page, .loading { position: relative; z-index: 2; }
17378    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17379    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17380    .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); }
17381    .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; }
17382    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17383    .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)); }
17384    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17385    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17386    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17387    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17388    .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; }
17389    .nav-project-pill.visible { display:inline-flex; }
17390    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17391    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17392    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17393    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17394    @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; } }
17395    .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; }
17396    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17397    .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; }
17398    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17399    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17400    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17401    .theme-toggle .icon-sun { display:none; }
17402    body.dark-theme .theme-toggle .icon-sun { display:block; }
17403    body.dark-theme .theme-toggle .icon-moon { display:none; }
17404    .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;}
17405    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17406    .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);}
17407    .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;}
17408    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17409    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17410    .settings-modal-body{padding:14px 16px 16px;}
17411    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17412    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17413    .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;}
17414    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17415    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17416    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17417    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17418    .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;}
17419    .tz-select:focus{border-color:var(--oxide);}
17420    .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; }
17421    .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;}
17422    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17423    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17424    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17425    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17426    .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; }
17427    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17428    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17429    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17430    .wb-stats-header { padding: 10px 24px 0; }
17431    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17432    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17433    .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; }
17434    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17435    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17436    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17437    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17438    .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; }
17439    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17440    .ws-stat-analyzers { position: relative; }
17441    .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; }
17442    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17443    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17444    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17445    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17446    .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; }
17447    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17448    .ws-divider { display: none; }
17449    .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%; }
17450    .ws-path-link:hover { color:var(--oxide); }
17451    body.dark-theme .ws-path-link { color:var(--oxide); }
17452    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17453    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17454    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17455    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17456    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17457    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17458    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17459    .ws-mini-box-lg { flex:2 1 0; }
17460    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17461    .ws-mini-box-br { flex:1.5 1 0; }
17462    .scope-legend-row { display:flex; flex-direction:row; align-items:center; justify-content:flex-start; flex-wrap:nowrap; gap:0; padding:5px 10px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:12px; width:100%; min-width:0; border-left:3px solid var(--line-strong); white-space:nowrap; }
17463    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17464    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17465    #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; }
17466    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17467    .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; }
17468    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17469    .git-source-banner strong { font-weight:800; color:var(--text); }
17470    .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; }
17471    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17472    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17473    .git-source-banner a:hover { text-decoration:underline; }
17474    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17475    .path-scope-sep { background:var(--line); margin:4px 14px; }
17476    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17477    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17478    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17479    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17480    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17481    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17482    .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; }
17483    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17484    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17485    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17486    .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; }
17487    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17488    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17489    [data-wb-tip] { cursor:help; }
17490    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17491    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17492    .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; }
17493    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17494    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17495    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17496    .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; }
17497    .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); }
17498    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17499    .side-info-card { padding: 18px; }
17500    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17501    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17502    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17503    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17504    .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); }
17505    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17506    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17507    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17508    .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; }
17509    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17510    .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; }
17511    .side-stack::-webkit-scrollbar { display: none; }
17512    .step-nav { padding: 20px 16px; }
17513    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17514    .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; }
17515    .step-button:hover { background: var(--surface-2); }
17516    .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); }
17517    .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; }
17518    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17519    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17520    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17521    .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); }
17522    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17523    .step-nav-sum-row:last-child { border-bottom:none; }
17524    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17525    .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; }
17526    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17527    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17528    .quick-scan-section { padding: 10px 4px 14px; }
17529    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17530    .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; }
17531    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17532    .quick-scan-btn:active { transform:translateY(0); }
17533    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17534    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17535    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17536    @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);} }
17537    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17538    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17539    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17540    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17541    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17542    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17543    .step-button.done .step-check { opacity:1; }
17544    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17545    .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; }
17546    .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; }
17547    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17548    .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; }
17549    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17550    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17551    .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; }
17552    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17553    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17554    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17555    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17556    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17557    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17558    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17559    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17560    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17561    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17562    .card-body { padding: 22px; }
17563    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17564    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17565    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17566    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17567    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17568    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17569    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17570    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17571    .field { min-width:0; }
17572    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17573    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; }
17574    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); }
17575    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17576    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); }
17577    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17578    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17579    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17580    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17581    .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; }
17582    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17583    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17584    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17585    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17586    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17587    .input-group.compact { grid-template-columns: 1fr auto auto; }
17588    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17589    .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)); }
17590    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17591    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17592    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17593    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17594    .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; }
17595    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17596    .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; }
17597    .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); }
17598    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17599    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17600    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17601    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17602    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17603    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17604    @media (max-height: 1200px) { .workbench-strip { margin-bottom: 12px; } .wb-stats-header { padding: 8px 20px 0; } .ws-left { padding: 10px 16px 12px; } .ws-history-group { padding: 12px 20px; } }
17605    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17606    button.secondary { background: var(--surface); }
17607    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17608    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17609    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17610    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17611    .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); }
17612    .section + .wizard-actions { border-top: none; padding-top: 0; }
17613    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17614    .default-path-overlay { position: fixed; inset: 0; z-index: 9000; background: rgba(0,0,0,0.52); display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; pointer-events: none; transition: opacity .18s ease; }
17615    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17616    .default-path-modal { background: var(--surface); border: 1px solid var(--line); border-radius: 20px; max-width: 682px; width: 100%; box-shadow: 0 30px 80px rgba(0,0,0,0.34); padding: 33px 37px 29px; transform: translateY(10px); transition: transform .18s ease; }
17617    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17618    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17619    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17620    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17621    .default-path-modal p code { background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 5px; font-size: 11.5px; color: var(--text); }
17622    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17623    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17624    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17625    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17626    .field-help-grid.coupled-help { margin-top: 12px; }
17627    .field-help-grid.preset-grid { align-items: start; }
17628    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17629    .preset-inline-row .field { margin: 0; }
17630    .preset-inline-row .explainer-card { margin: 0; }
17631    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17632    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17633    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17634    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17635    .preset-kv-row > :last-child { flex:1; min-width:0; }
17636    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17637    .output-field-row .field { margin: 0; }
17638    .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; }
17639    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17640    .step3-subtitle { margin-bottom: 10px; max-width: none; }
17641    .counting-intro { margin-bottom: 8px; max-width: none; }
17642    .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; }
17643    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17644    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17645    .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; }
17646    .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; }
17647    .section-spacer-top { margin-top: 28px; }
17648    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17649    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17650    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17651    .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); }
17652    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17653    .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; }
17654    .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; }
17655    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17656    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17657    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17658    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17659    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17660    .include-scope-badge { display:flex; align-items:center; gap:7px; padding:7px 12px; border-radius:8px; font-size:12px; font-weight:700; margin-bottom:7px; transition:background .2s,color .2s,border-color .2s; }
17661    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17662    .include-scope-badge.scope-narrow { background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.22); color:var(--nav,#b85d33); }
17663    body.dark-theme .include-scope-badge.scope-all { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
17664    body.dark-theme .include-scope-badge.scope-narrow { background:rgba(210,130,70,0.12); border-color:rgba(210,130,70,0.3); color:#e0a060; }
17665    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17666    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17667    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17668    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17669    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17670    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17671    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17672    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17673    .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); }
17674    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17675    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17676    .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; }
17677    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17678    .always-tracked-tip { display:flex; align-items:flex-start; gap: 14px; padding: 16px 18px; border-radius: 14px; border: 1px solid rgba(37,99,235,0.18); background: linear-gradient(135deg, rgba(37,99,235,0.05), rgba(37,99,235,0.02)); margin-top: 8px; width:100%; box-sizing:border-box; }
17679    .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; }
17680    .always-tracked-tip-body { flex:1; min-width:0; }
17681    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17682    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17683    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17684    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17685    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17686    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17687    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17688    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17689    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17690    .advanced-rule-description strong { color: var(--text); }
17691    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17692    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17693    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17694    .review-link:hover { text-decoration: underline; }
17695    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17696    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17697    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17698    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17699    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17700    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17701    .review-card ul { padding-left: 18px; margin: 0; }
17702    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17703    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17704    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17705    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17706    .review-card { min-height: 0; }
17707    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17708    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17709    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17710    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17711    .lang-overflow-chip { position:relative; cursor:default; }
17712    .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; }
17713    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17714    .git-inline-row { align-items:start; }
17715    .mixed-line-card { display:flex; flex-direction:column; }
17716    .preset-inline-row .toggle-card { justify-content: center; }
17717        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17718    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17719    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17720    .explorer-title { font-size: 18px; font-weight: 850; }
17721    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17722    .explorer-subtitle.wide { max-width: none; }
17723    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17724    .better-spacing { align-items:flex-start; justify-content:flex-end; }
17725    .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; }
17726    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17727    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17728    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17729    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17730    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17731    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17732    .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; }
17733    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17734    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17735    .scope-stat-button.supported { background: var(--success-bg); }
17736    .scope-stat-button.skipped { background: var(--warn-bg); }
17737    .scope-stat-button.unsupported { background: var(--danger-bg); }
17738    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17739    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17740    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17741    [data-tooltip] { position: relative; }
17742    [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); }
17743    [data-tooltip]:hover::after { display: block; }
17744    .scope-stat-button[data-tooltip] { cursor: pointer; }
17745    .badge[data-tooltip] { cursor: help; }
17746    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17747    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17748    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17749    .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; }
17750    .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; }
17751    code { display:inline-block; margin-top:0; padding:2px 7px; }
17752    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17753    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17754    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17755    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17756    .language-pill.muted-pill { color: var(--muted); }
17757    button.language-pill { appearance:none; cursor:pointer; }
17758    .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); }
17759    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17760    .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; }
17761    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17762    .file-explorer-search-row { margin-left: auto; }
17763    .explorer-filter-select { min-width: 170px; width: 170px; }
17764    .explorer-search { min-width: 300px; width: 300px; }
17765    .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); }
17766    .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; }
17767    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17768    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17769    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17770    .file-explorer-tree { max-height: 640px; overflow:auto; }
17771    .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); }
17772    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17773    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17774    .tree-row.hidden-by-filter { display:none !important; }
17775    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17776    .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; }
17777    .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; }
17778    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17779    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17780    .tree-node { display:inline-flex; align-items:center; min-width:0; }
17781    .tree-node-dir { color: var(--text); font-weight: 800; }
17782    .tree-node-supported { color: var(--success-text); }
17783    .tree-node-skipped { color: var(--warn-text); }
17784    .tree-node-unsupported { color: var(--danger-text); }
17785    .tree-node-more { color: var(--muted-2); font-style: italic; }
17786    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17787    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17788    .tree-status-cell { display:flex; justify-content:flex-start; }
17789    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17790    .preview-warning { color: var(--warn-text); background: var(--warn-bg); border:1px solid var(--warn-text); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; font-size: 13px; line-height: 1.5; }
17791    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
17792    .preview-warning p { margin: 0 0 10px; }
17793    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
17794    .repo-pick { font-family: inherit; font-size: 12px; font-weight: 600; color: var(--warn-text); background: transparent; border:1px solid var(--warn-text); border-radius: 999px; padding: 4px 12px; cursor: pointer; transition: background .15s ease, color .15s ease; }
17795    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
17796    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
17797    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
17798    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
17799    .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; }
17800    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17801    .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; }
17802    @keyframes prevSpin { to { transform:rotate(360deg); } }
17803    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17804    .preview-gate-spinner { width:15px; height:15px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 15px; }
17805    .preview-gate-info { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; padding:0; border:none; background:transparent; color:var(--oxide); cursor:pointer; border-radius:50%; flex:0 0 18px; transition:transform .15s ease, color .15s ease; }
17806    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17807    .preview-gate-info svg { width:16px; height:16px; }
17808    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17809    @keyframes previewPanelFlash { 0%,100% { box-shadow:0 0 0 0 rgba(196,93,42,0); } 25% { box-shadow:0 0 0 4px rgba(196,93,42,0.45); } }
17810    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17811    .preview-loading-text { flex:1; min-width:0; }
17812    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17813    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17814    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17815    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17816    .cov-scan-idle { display:none; }
17817    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17818    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17819    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17820    .cov-scan-title { font-weight:600; font-size:12.5px; }
17821    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17822    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17823    .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; }
17824    .cov-scan-use:hover { opacity:.75; }
17825    .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; }
17826    .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; }
17827    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17828    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17829    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17830    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17831    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17832    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17833    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17834    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17835    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17836    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17837    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17838    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17839    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17840    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17841    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17842    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17843    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17844    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17845    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17846    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17847    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17848    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17849    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17850    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17851    .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); }
17852    .loading.active { display:flex; }
17853    /* Lock page scroll while the analysis modal is open so the removed scrollbar
17854       gutter doesn't pull the centered card slightly left of true center. */
17855    body.modal-open { overflow: hidden; }
17856    .loading-card { position:relative; overflow:hidden; width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
17857    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17858    .loading-card::before { content:''; position:absolute; inset:0; z-index:0; pointer-events:none; border-radius:inherit; opacity:0; background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.22), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(37,99,235,0.16), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.14), transparent 60%); transition: opacity .4s ease; }
17859    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17860    .loading-card > * { position:relative; z-index:1; }
17861    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17862    body.dark-theme .loading-card::before { background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.26), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(111,155,255,0.18), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.18), transparent 60%); }
17863    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17864    .progress-bar span { display:block; width:35%; height:100%; border-radius:999px; background: linear-gradient(90deg, transparent, var(--accent-2) 22%, var(--oxide,#d37a4c) 78%, transparent); will-change: transform; animation: pulseBar 1.5s linear infinite; }
17865    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17866    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17867    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17868    .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:18px;display:flex;align-items:center;gap:10px; }
17869    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17870    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17871    .lc-metric-label { font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17872    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17873    .lc-stage-desc { font-size:12px;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:9px 14px;margin-bottom:18px;line-height:1.5;transition:opacity .3s; }
17874    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17875    .lc-step { display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;color:var(--muted);border:1.5px solid transparent;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;transition:all .25s; }
17876    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17877    .lc-step.done { color:var(--muted);opacity:0.55; }
17878    .lc-step-num { width:18px;height:18px;border-radius:50%;background:rgba(150,140,130,0.2);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;flex:0 0 auto; }
17879    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17880    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17881    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17882    .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; }
17883    .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; }
17884    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17885    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17886    .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; }
17887    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17888    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17889    .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; }
17890    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17891    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17892    .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; }
17893    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17894    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17895    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17896    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17897    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17898    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17899    .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; }
17900    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17901    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17902    .hidden { display:none !important; }
17903    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17904    .site-footer a{color:var(--muted);}
17905    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17906    @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; } }
17907    .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;}
17908    @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));}}
17909    .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;}
17910    .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; }
17911    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17912    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17913    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17914    .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; }
17915    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17916    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17917    .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(7px); background:var(--text); color:var(--bg); padding:5px 10px; border-radius:7px; font-size:11px; font-weight:600; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; }
17918    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17919    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17920    .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; }
17921    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17922    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17923    .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; }
17924    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17925    .info-icon-btn:hover { color:var(--text); }
17926    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); }
17927    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17928    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17929    .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;}
17930    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17931    .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;}
17932    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17933    #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);}
17934    #offline-file-banner.show{display:flex;}
17935    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17936    #offline-file-banner .ofb-text{flex:1;}
17937    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17938    #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;}
17939    #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;}
17940    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17941    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17942    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17943    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17944    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17945    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17946    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17947  </style>
17948</head>
17949<body id="page-top">
17950  <div id="offline-file-banner" role="alert">
17951    <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>
17952    <span class="ofb-text">
17953      Charts, images, and navigation require the oxide-sloc server.
17954      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17955      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17956      The metric tables below are fully readable without the server.
17957    </span>
17958    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17959  </div>
17960  <script nonce="{{ csp_nonce }}">(function(){if(location.protocol==='file:'){var b=document.getElementById('offline-file-banner');if(b)b.classList.add('show');var d=document.getElementById('ofb-dismiss-btn');if(d)d.addEventListener('click',function(){b.classList.remove('show');});}})();</script>
17961  <div class="background-watermarks" aria-hidden="true">
17962    <img src="/images/logo/logo-text.png" alt="" />
17963    <img src="/images/logo/logo-text.png" alt="" />
17964    <img src="/images/logo/logo-text.png" alt="" />
17965    <img src="/images/logo/logo-text.png" alt="" />
17966    <img src="/images/logo/logo-text.png" alt="" />
17967    <img src="/images/logo/logo-text.png" alt="" />
17968    <img src="/images/logo/logo-text.png" alt="" />
17969    <img src="/images/logo/logo-text.png" alt="" />
17970    <img src="/images/logo/logo-text.png" alt="" />
17971    <img src="/images/logo/logo-text.png" alt="" />
17972    <img src="/images/logo/logo-text.png" alt="" />
17973    <img src="/images/logo/logo-text.png" alt="" />
17974    <img src="/images/logo/logo-text.png" alt="" />
17975    <img src="/images/logo/logo-text.png" alt="" />
17976  </div>
17977  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17978  <div class="top-nav">
17979    <div class="top-nav-inner">
17980      <a class="brand" href="/">
17981        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17982        <div class="brand-copy">
17983          <div class="brand-title">OxideSLOC</div>
17984          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17985        </div>
17986      </a>
17987      <div class="nav-project-slot">
17988        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17989          <span class="nav-project-label">Project</span>
17990          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17991        </div>
17992      </div>
17993      <div class="nav-status">
17994        <a class="nav-pill" href="/">Home</a>
17995        <div class="nav-dropdown">
17996          <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>
17997          <div class="nav-dropdown-menu">
17998            <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>
17999          </div>
18000        </div>
18001        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18002        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18003        <div class="nav-dropdown">
18004          <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>
18005          <div class="nav-dropdown-menu">
18006            <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>
18007          </div>
18008        </div>
18009        <div class="server-status-wrap" id="server-status-wrap">
18010          <div class="nav-pill server-online-pill" id="server-status-pill">
18011            <span class="status-dot" id="status-dot"></span>
18012            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18013            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18014          </div>
18015          <div class="server-status-tip">
18016            {% if server_mode %}
18017            OxideSLOC is running in server mode — accessible on your LAN.
18018            {% else %}
18019            OxideSLOC is running locally — only accessible from this machine.
18020            {% endif %}
18021            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18022          </div>
18023        </div>
18024        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18025          <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>
18026        </button>
18027        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18028          <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>
18029          <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>
18030        </button>
18031      </div>
18032    </div>
18033  </div>
18034
18035  <div class="loading" id="loading">
18036    <div class="loading-card" id="loading-card">
18037      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18038      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18039      <div class="lc-path" id="lc-path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" style="flex:0 0 auto;opacity:0.45"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span id="lc-path-text"></span></div>
18040      <div class="lc-steps" id="lc-steps">
18041        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18042        <div class="lc-step-arrow">›</div>
18043        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18044        <div class="lc-step-arrow">›</div>
18045        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18046        <div class="lc-step-arrow">›</div>
18047        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18048      </div>
18049      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18050      <div class="lc-metrics" id="lc-metrics">
18051        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18052        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18053        <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>
18054        <div class="lc-metric hidden" id="lc-speed-card"><div class="lc-metric-label">Files/sec</div><div class="lc-metric-value" id="lc-speed">—</div></div>
18055      </div>
18056      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18057      <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>
18058      <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>
18059      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18060      <div class="lc-actions hidden" id="lc-actions">
18061        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18062        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18063      </div>
18064      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18065        <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>
18066        Cancel scan
18067      </button>
18068    </div>
18069  </div>
18070
18071  <div class="page">
18072    <div class="workbench-strip">
18073      <div class="workbench-box wb-stats">
18074        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18075          <span class="wb-stats-title">Analysis session</span>
18076        </div>
18077        <div class="ws-left">
18078          <div class="ws-stat ws-stat-analyzers">
18079            <span class="ws-label">Analyzers</span>
18080            <span class="ws-value">
18081              <span class="ws-badge">60 languages</span>
18082            </span>
18083            <div class="ws-lang-tooltip">
18084              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18085              <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>
18086              <div class="ws-lang-grid">
18087                <span class="ws-lang-item">Assembly</span>
18088                <span class="ws-lang-item">C</span>
18089                <span class="ws-lang-item">C++</span>
18090                <span class="ws-lang-item">C#</span>
18091                <span class="ws-lang-item">Clojure</span>
18092                <span class="ws-lang-item">CSS</span>
18093                <span class="ws-lang-item">Dart</span>
18094                <span class="ws-lang-item">Dockerfile</span>
18095                <span class="ws-lang-item">Elixir</span>
18096                <span class="ws-lang-item">Erlang</span>
18097                <span class="ws-lang-item">F#</span>
18098                <span class="ws-lang-item">Go</span>
18099                <span class="ws-lang-item">Groovy</span>
18100                <span class="ws-lang-item">Haskell</span>
18101                <span class="ws-lang-item">HTML</span>
18102                <span class="ws-lang-item">Java</span>
18103                <span class="ws-lang-item">JavaScript</span>
18104                <span class="ws-lang-item">Julia</span>
18105                <span class="ws-lang-item">Kotlin</span>
18106                <span class="ws-lang-item">Lua</span>
18107                <span class="ws-lang-item">Makefile</span>
18108                <span class="ws-lang-item">Nim</span>
18109                <span class="ws-lang-item">Obj-C</span>
18110                <span class="ws-lang-item">OCaml</span>
18111                <span class="ws-lang-item">Perl</span>
18112                <span class="ws-lang-item">PHP</span>
18113                <span class="ws-lang-item">PowerShell</span>
18114                <span class="ws-lang-item">Python</span>
18115                <span class="ws-lang-item">R</span>
18116                <span class="ws-lang-item">Ruby</span>
18117                <span class="ws-lang-item">Rust</span>
18118                <span class="ws-lang-item">Scala</span>
18119                <span class="ws-lang-item">SCSS</span>
18120                <span class="ws-lang-item">Shell</span>
18121                <span class="ws-lang-item">SQL</span>
18122                <span class="ws-lang-item">Svelte</span>
18123                <span class="ws-lang-item">Swift</span>
18124                <span class="ws-lang-item">TypeScript</span>
18125                <span class="ws-lang-item">Vue</span>
18126                <span class="ws-lang-item">XML</span>
18127                <span class="ws-lang-item">Zig</span>
18128                <span class="ws-lang-item">Solidity</span>
18129                <span class="ws-lang-item">Protobuf</span>
18130                <span class="ws-lang-item">HCL</span>
18131                <span class="ws-lang-item">GraphQL</span>
18132                <span class="ws-lang-item">Ada</span>
18133                <span class="ws-lang-item">VHDL</span>
18134                <span class="ws-lang-item">Verilog</span>
18135                <span class="ws-lang-item">Tcl</span>
18136                <span class="ws-lang-item">Pascal</span>
18137                <span class="ws-lang-item">Visual Basic</span>
18138                <span class="ws-lang-item">Lisp</span>
18139                <span class="ws-lang-item">Fortran</span>
18140                <span class="ws-lang-item">Nix</span>
18141                <span class="ws-lang-item">Crystal</span>
18142                <span class="ws-lang-item">D</span>
18143                <span class="ws-lang-item">GLSL</span>
18144                <span class="ws-lang-item">CMake</span>
18145                <span class="ws-lang-item">Elm</span>
18146                <span class="ws-lang-item">Awk</span>
18147              </div>
18148            </div>
18149          </div>
18150          <div class="ws-divider"></div>
18151          <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>
18152          <div class="ws-divider"></div>
18153          <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.">
18154            <span class="ws-label">Output</span>
18155            <span class="ws-value">
18156              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18157                <span id="ws-output-root">project/sloc</span>
18158              </button>
18159            </span>
18160          </div>
18161        </div>
18162      </div>
18163      <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.">
18164        <div class="ws-history-label">Scan history</div>
18165        <div class="ws-history-inner">
18166          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18167            <div class="ws-mini-label">Scans</div>
18168            <div class="ws-mini-value" id="ws-scan-count">—</div>
18169          </div>
18170          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18171            <div class="ws-mini-label">Last Scan</div>
18172            <div class="ws-mini-value" id="ws-last-scan">—</div>
18173          </div>
18174          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18175            <div class="ws-mini-label">Branch</div>
18176            <div class="ws-mini-value" id="ws-branch">—</div>
18177          </div>
18178        </div>
18179      </div>
18180    </div>
18181
18182    <div class="layout">
18183      <aside class="side-stack">
18184        <section class="step-nav">
18185        <h3>Guided scan setup</h3>
18186        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18187          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18188          Top of page
18189        </a>
18190        <button type="button" class="step-button active" style="margin-top:10px;" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18191        <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>
18192        <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>
18193        <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>
18194
18195        <div class="step-steps-divider"></div>
18196
18197        <div class="step-nav-info" id="step-nav-info">
18198          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18199          <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>
18200        </div>
18201
18202        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18203          <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>
18204          <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>
18205          <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>
18206        </div>
18207
18208        <div class="quick-scan-divider"></div>
18209        <div class="quick-scan-section">
18210          <div class="quick-scan-label">No customization needed?</div>
18211          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18212            <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>
18213            Quick Scan
18214          </button>
18215          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18216        </div>
18217
18218        <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>
18219        <div class="sidebar-scroll-divider"></div>
18220        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18221          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18222          Skip to bottom
18223        </a>
18224        </section>
18225
18226      </aside>
18227
18228      <section class="card">
18229        <div class="card-header">
18230          <div class="card-title-row">
18231            <div>
18232              <h1 class="card-title">Guided scan configuration</h1>
18233              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18234            </div>
18235            <div class="wizard-progress" aria-label="Scan setup progress">
18236              <div class="wizard-progress-top">
18237                <span class="wizard-progress-label">Setup progress</span>
18238                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18239              </div>
18240              <div class="wizard-progress-track">
18241                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18242              </div>
18243            </div>
18244          </div>
18245        </div>
18246        <div class="card-body">
18247          <form method="post" action="/analyze" id="analyze-form">
18248            <div class="wizard-step active" data-step="1">
18249              <div class="section">
18250                <div class="section-kicker">Step 1</div>
18251                <h2>Select project and preview scope</h2>
18252                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18253                <div class="field">
18254                  <label for="path">Project path</label>
18255                  {% if !git_repo.is_empty() %}
18256                  <div class="git-source-banner">
18257                    <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>
18258                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18259                    <a href="/git-browser">← Back to Git Browser</a>
18260                  </div>
18261                  {% endif %}
18262                  <div class="path-scope-grid">
18263                      {% if !git_repo.is_empty() %}
18264                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18265                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18266                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18267                      {% else %}
18268                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18269                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18270                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18271                      {% endif %}
18272                    <div class="path-scope-sep"></div>
18273                    <div class="scope-legend-row">
18274                      <span class="scope-legend-label">Scope legend:</span>
18275                      <span class="scope-legend-badges">
18276                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18277                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18278                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18279                      </span>
18280                    </div>
18281                  </div>
18282                  {% if git_repo.is_empty() %}
18283                  {% if server_mode %}
18284                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18285                    ℹ️ Files are compressed and streamed — no fixed size limit.
18286                  </div>
18287                  {% endif %}
18288                  <div class="path-info-row">
18289                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18290                      <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>
18291                      <span id="project-size-text">Project size: —</span>
18292                    </button>
18293                  </div>
18294                  {% else %}
18295                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18296                  {% endif %}
18297                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18298                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18299                </div>
18300
18301                <div class="scope-preview-divider" aria-hidden="true"></div>
18302
18303                <div id="preview-panel">
18304                  <div class="preview-error">Loading preview...</div>
18305                </div>
18306              </div>
18307
18308              <div class="section" style="margin-top:14px;">
18309                <div class="preset-inline-row git-inline-row">
18310                  <div class="toggle-card" style="margin:0;">
18311                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18312                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18313                    <label class="checkbox">
18314                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18315                      <div>
18316                        <span>Detect and separate git submodules</span>
18317                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18318                      </div>
18319                    </label>
18320                  </div>
18321                  <div class="explainer-card prominent" style="margin:0;">
18322                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18323                    <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>
18324                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18325    path = libs/core
18326    url  = https://github.com/org/core.git
18327
18328[submodule "libs/ui"]
18329    path = libs/ui
18330    url  = https://github.com/org/ui.git</div>
18331                  </div>
18332                </div>
18333              </div>
18334
18335              <div class="section">
18336                <div class="field-grid">
18337                  <div class="field">
18338                    <div class="glob-label-row">
18339                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18340                      <div id="include-scope-badge" class="include-scope-badge scope-all" aria-live="polite" style="margin:0;padding:4px 10px;font-size:11px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> All files eligible &mdash; no include filter active</div>
18341                    </div>
18342                    <textarea id="include_globs" name="include_globs" class="glob-textarea" placeholder="Leave blank to scan everything&#10;&#10;Or narrow scope with patterns:&#10;src/**/*.py&#10;lib/**/*.js&#10;scripts/*.sh"></textarea>
18343                    <div class="hint"><strong>Leave blank to scan everything</strong> under the project path. Only add patterns here when you want to limit the scan to specific folders or file types. Patterns are line- or comma-separated and relative to the project path.</div>
18344                  </div>
18345                  <div class="field">
18346                    <div class="glob-label-row">
18347                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18348                    </div>
18349                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
18350                    <div id="quick-exclude-chips" class="quick-excl-row">
18351                      <span class="quick-excl-label">Quick add:</span>
18352                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18353                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18354                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18355                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18356                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18357                      <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>
18358                    </div>
18359                    <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>
18360                  </div>
18361                </div>
18362                <div class="glob-guidance-grid">
18363                  <div class="glob-guidance-card">
18364                    <strong>How to read them</strong>
18365                    <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>
18366                  </div>
18367                  <div class="glob-guidance-card">
18368                    <strong>Common include examples</strong>
18369                    <p><strong>Empty (default)</strong> — scans everything. <code>src/**/*.rs</code> only Rust sources, <code>scripts/*</code> top-level scripts only, <code>tests/**</code> everything under tests.</p>
18370                  </div>
18371                  <div class="glob-guidance-card">
18372                    <strong>Common exclude examples</strong>
18373                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18374                  </div>
18375                </div>
18376              </div>
18377
18378              <div class="section" style="margin-top:14px;">
18379                <div class="preset-inline-row git-inline-row">
18380                  <div class="toggle-card" style="margin:0;">
18381                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18382                    <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>
18383                    <div class="field" style="margin:0;">
18384                      <div class="input-group compact">
18385                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18386                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18387                      </div>
18388                      <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>
18389                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18390                    </div>
18391                  </div>
18392                  <div class="explainer-card prominent" style="margin:0;">
18393                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18394                    <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>
18395                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18396lcov --capture --directory . --output-file coverage/lcov.info
18397
18398# C / C++ — llvm-cov (LCOV)
18399llvm-profdata merge -sparse default.profraw -o default.profdata
18400llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18401
18402# C# — coverlet (Cobertura XML)
18403dotnet test --collect:"XPlat Code Coverage"
18404
18405# Python — pytest-cov (Cobertura XML)
18406pytest --cov --cov-report=xml
18407
18408# Python — coverage.py native JSON
18409coverage run -m pytest && coverage json   # writes coverage.json
18410
18411# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18412./gradlew jacocoTestReport</div>
18413                  </div>
18414                </div>
18415              </div>
18416
18417              <div class="wizard-actions">
18418                <div class="left"></div>
18419                <div class="right">
18420                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18421                    <span class="preview-gate-spinner" aria-hidden="true"></span>
18422                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
18423                    <button type="button" class="preview-gate-info" id="preview-gate-info" title="What is this? Jump up to the live scope preview" aria-label="Show what is being scanned — jump to the scope preview">
18424                      <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>
18425                    </button>
18426                  </div>
18427                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18428                </div>
18429              </div>
18430            </div>
18431
18432            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18433              <div class="default-path-modal">
18434                <h3 id="default-path-title">
18435                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
18436                  Proceed with the default sample test?
18437                </h3>
18438                <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18439                <p>You haven&#39;t selected your own project yet.</p>
18440                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18441                <div class="default-path-actions">
18442                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18443                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18444                </div>
18445              </div>
18446            </div>
18447
18448            <div class="wizard-step" data-step="2">
18449              <div class="section">
18450                <div class="section-kicker">Step 2</div>
18451                <h2>Choose counting behavior</h2>
18452                <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>
18453<div class="subsection-bar">Primary line classification</div>
18454                <div class="preset-kv-row">
18455                  <div class="toggle-card mixed-line-card" style="margin:0;">
18456                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18457                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18458                    <select id="mixed_line_policy" name="mixed_line_policy">
18459                      <option value="code_only">Code only</option>
18460                      <option value="code_and_comment">Code and comment</option>
18461                      <option value="comment_only">Comment only</option>
18462                      <option value="separate_mixed_category">Separate mixed category</option>
18463                    </select>
18464                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18465                  </div>
18466                  <div class="explainer-card prominent" style="margin:0;">
18467                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18468                    <div class="explainer-body" id="mixed-policy-description"></div>
18469                    <div class="code-sample" id="mixed-policy-example"></div>
18470                  </div>
18471                </div>
18472              </div>
18473
18474              <div class="subsection-bar">Additional scan rules</div>
18475              <div class="scan-rules-grid">
18476                <div class="preset-inline-row">
18477                  <div class="toggle-card" style="margin:0;">
18478                    <div class="field-help-title">Generated files</div>
18479                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18480                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18481                  </div>
18482                  <div class="explainer-card prominent" style="margin:0;">
18483                    <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>
18484                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18485# Files matching codegen patterns are excluded:
18486#   *.generated.cs  *.pb.go  *.g.dart</div>
18487                  </div>
18488                </div>
18489                <div class="preset-inline-row">
18490                  <div class="toggle-card" style="margin:0;">
18491                    <div class="field-help-title">Minified files</div>
18492                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18493                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18494                  </div>
18495                  <div class="explainer-card prominent" style="margin:0;">
18496                    <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>
18497                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18498# Heuristic: very long lines + low whitespace ratio
18499#   jquery.min.js  bundle.min.css  → skipped</div>
18500                  </div>
18501                </div>
18502                <div class="preset-inline-row">
18503                  <div class="toggle-card" style="margin:0;">
18504                    <div class="field-help-title">Vendor directories</div>
18505                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18506                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18507                  </div>
18508                  <div class="explainer-card prominent" style="margin:0;">
18509                    <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>
18510                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18511# Directories named vendor/ node_modules/ third_party/
18512#   → entire subtree is excluded from totals</div>
18513                  </div>
18514                </div>
18515                <div class="preset-inline-row">
18516                  <div class="toggle-card" style="margin:0;">
18517                    <div class="field-help-title">Lockfiles and manifests</div>
18518                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18519                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18520                  </div>
18521                  <div class="explainer-card prominent" style="margin:0;">
18522                    <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>
18523                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
18524# Files like package-lock.json  Cargo.lock  yarn.lock
18525#   → skipped unless this is enabled</div>
18526                  </div>
18527                </div>
18528                <div class="preset-inline-row">
18529                  <div class="toggle-card" style="margin:0;">
18530                    <div class="field-help-title">Binary handling</div>
18531                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18532                    <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>
18533                  </div>
18534                  <div class="explainer-card prominent" style="margin:0;">
18535                    <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>
18536                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
18537# Detected via long lines + low whitespace heuristic
18538#   .png  .exe  .so  → skipped silently</div>
18539                  </div>
18540                </div>
18541                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18542                  <div class="toggle-card" style="margin:0;">
18543                    <div class="field-help-title">Python docstrings</div>
18544                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18545                    <label class="checkbox">
18546                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18547                      <span>Count as comment-style lines</span>
18548                    </label>
18549                  </div>
18550                  <div class="explainer-card prominent" style="margin:0;">
18551                    <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>
18552                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18553                  </div>
18554                </div>
18555              </div>
18556              <div class="subsection-bar">IEEE 1045-1992 counting</div>
18557              <div class="scan-rules-grid">
18558                <div class="preset-inline-row">
18559                  <div class="toggle-card" style="margin:0;">
18560                    <div class="field-help-title">Continuation lines</div>
18561                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18562                    <select name="continuation_line_policy" id="continuation_line_policy">
18563                      <option value="each_physical_line" selected>Each physical line (default)</option>
18564                      <option value="collapse_to_logical">Collapse to logical line</option>
18565                    </select>
18566                  </div>
18567                  <div class="explainer-card prominent" style="margin:0;">
18568                    <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>
18569                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18570    ((a) &gt; (b) ? (a) : (b))
18571# each_physical_line → 2 SLOC
18572# collapse_to_logical → 1 SLOC</div>
18573                  </div>
18574                </div>
18575                <div class="preset-inline-row">
18576                  <div class="toggle-card" style="margin:0;">
18577                    <div class="field-help-title">Block-comment blanks</div>
18578                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18579                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18580                      <option value="count_as_comment" selected>Count as comment (default)</option>
18581                      <option value="count_as_blank">Count as blank</option>
18582                    </select>
18583                  </div>
18584                  <div class="explainer-card prominent" style="margin:0;">
18585                    <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>
18586                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18587 * Summary line
18588 *              ← blank inside block comment
18589 * Detail line
18590 */
18591# count_as_comment → blank counts toward comments
18592# count_as_blank   → blank counts toward blanks</div>
18593                  </div>
18594                </div>
18595                <div class="preset-inline-row">
18596                  <div class="toggle-card" style="margin:0;">
18597                    <div class="field-help-title">Compiler directives</div>
18598                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18599                    <select name="count_compiler_directives" id="count_compiler_directives">
18600                      <option value="enabled" selected>Include in code SLOC (default)</option>
18601                      <option value="disabled">Exclude from code SLOC</option>
18602                    </select>
18603                  </div>
18604                  <div class="explainer-card prominent" style="margin:0;">
18605                    <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>
18606                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
18607#define BUF 256     ← compiler directive
18608int main() { … }   ← code
18609# enabled  → 3 code SLOC
18610# disabled → 1 code SLOC + 2 directive lines</div>
18611                  </div>
18612                </div>
18613              </div>
18614
18615              <div class="subsection-bar">Code Style Analysis</div>
18616              <div class="scan-rules-grid">
18617                <div class="preset-inline-row">
18618                  <div class="toggle-card" style="margin:0;">
18619                    <div class="field-help-title">Style analysis</div>
18620                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18621                    <select name="style_analysis_enabled" id="style_analysis_enabled">
18622                      <option value="enabled" selected>Enabled (default)</option>
18623                      <option value="disabled">Disabled — skip style scoring</option>
18624                    </select>
18625                  </div>
18626                  <div class="explainer-card prominent" style="margin:0;">
18627                    <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>
18628                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
18629# style_analysis_enabled = false  (skip, faster scan)
18630# Disabling removes the Code Style section from the report.</div>
18631                  </div>
18632                </div>
18633                <div class="preset-inline-row">
18634                  <div class="toggle-card" style="margin:0;">
18635                    <div class="field-help-title">Column-width threshold</div>
18636                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18637                    <select name="style_col_threshold" id="style_col_threshold">
18638                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18639                      <option value="100">100 columns (Uber Go, Google Java)</option>
18640                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
18641                    </select>
18642                  </div>
18643                  <div class="explainer-card prominent" style="margin:0;">
18644                    <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>
18645                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
18646# style_col_threshold = 100 (Uber Go, Google Java)
18647# style_col_threshold = 120 (Uber Go max, Kotlin)
18648# Files where &lt;= 5% of lines exceed the limit
18649# are counted as "N-col compliant" in the report.</div>
18650                  </div>
18651                </div>
18652                <div class="preset-inline-row">
18653                  <div class="toggle-card" style="margin:0;">
18654                    <div class="field-help-title">Score alert threshold</div>
18655                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18656                    <select name="style_score_threshold" id="style_score_threshold">
18657                      <option value="0" selected>Off — no threshold (default)</option>
18658                      <option value="40">40% — flag poorly styled files</option>
18659                      <option value="50">50% — flag below-average files</option>
18660                      <option value="60">60% — flag below-good files</option>
18661                      <option value="70">70% — flag below-strong files</option>
18662                    </select>
18663                  </div>
18664                  <div class="explainer-card prominent" style="margin:0;">
18665                    <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>
18666                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
18667# style_score_threshold = 50  (flag files &lt; 50%)
18668# Low-scoring files get a red left-border in the
18669# per-file style breakdown table.</div>
18670                  </div>
18671                </div>
18672              </div>
18673
18674              <div class="always-tracked-tip">
18675                <div class="always-tracked-tip-icon">ℹ</div>
18676                <div class="always-tracked-tip-body">
18677                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
18678                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
18679                  <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>
18680                </div>
18681              </div>
18682
18683              <div class="subsection-bar">Advanced Metrics</div>
18684              <div class="scan-rules-grid">
18685                <div class="preset-inline-row">
18686                  <div class="toggle-card" style="margin:0;">
18687                    <div class="field-help-title">COCOMO mode</div>
18688                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18689                    <select name="cocomo_mode" id="cocomo_mode">
18690                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18691                      <option value="semi_detached">Semi-detached — mixed constraints</option>
18692                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
18693                    </select>
18694                  </div>
18695                  <div class="explainer-card prominent" style="margin:0;">
18696                    <div class="advanced-rule-description"><strong>Purpose:</strong> Selects the COCOMO I Basic mode used to estimate development effort, schedule, and team size from code SLOC.<br /><strong>Organic</strong> — small teams with good experience on similar problems (most software projects).<br /><strong>Semi-detached</strong> — mixed experience; some novel aspects; medium-sized projects.<br /><strong>Embedded</strong> — tight hardware, OS, or real-time constraints; high innovation; large projects.</div>
18697                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
18698# Semi-detached: Effort = 3.0 × KSLOC^1.12
18699# Embedded:     Effort = 3.6 × KSLOC^1.20
18700# All modes: Schedule = 2.5 × Effort^d</div>
18701                  </div>
18702                </div>
18703                <div class="preset-inline-row">
18704                  <div class="toggle-card" style="margin:0;">
18705                    <div class="field-help-title">Complexity alert</div>
18706                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18707                    <input type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
18708                  </div>
18709                  <div class="explainer-card prominent" style="margin:0;">
18710                    <div class="advanced-rule-description"><strong>Purpose:</strong> When set, files whose total cyclomatic complexity score exceeds this threshold are highlighted in the results page with an accent border.<br /><strong>Complexity score</strong> counts branch decision keywords (if, for, while, ||, &amp;&amp;, …) across all code lines — a fast lexical approximation of McCabe complexity.<br /><strong>Common thresholds:</strong> 50 for a simple project, 100–200 for medium, 300+ for large repos.</div>
18711                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18712# 50  = flag any file with &gt; 50 branch points
18713# 100 = flag any file with &gt; 100 branch points
18714# Files above the threshold are highlighted
18715# in the result page metric strip.</div>
18716                  </div>
18717                </div>
18718                <div class="preset-inline-row">
18719                  <div class="toggle-card" style="margin:0;">
18720                    <div class="field-help-title">Git hotspots</div>
18721                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18722                    <input type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
18723                  </div>
18724                  <div class="explainer-card prominent" style="margin:0;">
18725                    <div class="advanced-rule-description"><strong>Purpose:</strong> <strong>On by default (90 days).</strong> oxide-sloc runs a single <code>git log</code> pass over the last N days and ranks files by <strong>code&nbsp;lines&nbsp;&times;&nbsp;recent&nbsp;commits</strong> in a Git Hotspots table — large files that change often are the strongest refactoring candidates.<br /><strong>Requires</strong> the scanned path to be a git repository. This is distinct from the scan-to-scan churn rate shown on the Compare page.</div>
18726                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
18727# 30  = last month of activity
18728# 365 = last year
18729# 0   = disable the hotspots table
18730# Adds Commits + Last-changed columns to CSV.</div>
18731                  </div>
18732                </div>
18733                <div class="preset-inline-row">
18734                  <div class="toggle-card" style="margin:0;">
18735                    <div class="field-help-title">Duplicate handling</div>
18736                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18737                    <select name="exclude_duplicates" id="exclude_duplicates">
18738                      <option value="disabled" selected>Detect and report only (default)</option>
18739                      <option value="enabled">Detect and exclude from SLOC totals</option>
18740                    </select>
18741                  </div>
18742                  <div class="explainer-card prominent" style="margin:0;">
18743                    <div class="advanced-rule-description"><strong>Purpose:</strong> Detects files with identical content (bit-for-bit copies) that would otherwise inflate SLOC counts.<br /><strong>Detect and report only</strong> — duplicates are counted normally in totals; a "Duplicate groups" chip in the result page shows how many groups exist (default).<br /><strong>Detect and exclude</strong> — only one file per identical-content group contributes to code/comment/blank line totals; the rest are silently excluded.</div>
18744                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18745# detect only   → all 3 counted in SLOC
18746# exclude dupes → 1 counted, 2 excluded
18747# Duplicate groups chip always shows the count.</div>
18748                  </div>
18749                </div>
18750                <div class="always-tracked-tip" style="margin:8px 0 0;">
18751                  <div class="always-tracked-tip-icon">ℹ</div>
18752                  <div class="always-tracked-tip-body">
18753                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
18754                    <div class="always-tracked-metrics-row">
18755                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18756                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
18757                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
18758                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
18759                    </div>
18760                    <div class="hint" style="margin-top:8px;">All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
18761                  </div>
18762                </div>
18763              </div>
18764
18765              <div class="wizard-actions">
18766                <div class="left">
18767                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18768                </div>
18769                <div class="right">
18770                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18771                </div>
18772              </div>
18773            </div>
18774
18775            <div class="wizard-step" data-step="3">
18776              <div class="section">
18777                <div class="section-kicker">Step 3</div>
18778                <h2>Output and report identity</h2>
18779                <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>
18780                <div class="preset-kv-row">
18781                  <div class="toggle-card" style="margin:0;">
18782                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18783                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18784                    <select id="scan_preset">
18785                      <option value="balanced">Balanced local scan</option>
18786                      <option value="code_focused">Code focused</option>
18787                      <option value="comment_audit">Comment audit</option>
18788                      <option value="deep_review">Deep review</option>
18789                    </select>
18790                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18791                  </div>
18792                  <div class="explainer-card">
18793                    <div class="field-help-title">Selected scan preset</div>
18794                    <div class="explainer-body" id="scan-preset-description"></div>
18795                    <div class="preset-summary-row" id="scan-preset-summary"></div>
18796                    <div class="code-sample" id="scan-preset-example"></div>
18797                    <div class="preset-note" id="scan-preset-note"></div>
18798                  </div>
18799                </div>
18800                <hr class="step3-separator" />
18801                <div class="preset-kv-row">
18802                  <div class="toggle-card" style="margin:0;">
18803                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18804                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18805                    <select id="artifact_preset">
18806                      <option value="review">Review bundle</option>
18807                      <option value="full">Full bundle</option>
18808                      <option value="html_only">HTML only</option>
18809                      <option value="machine">Machine bundle</option>
18810                    </select>
18811                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18812                  </div>
18813                  <div class="explainer-card">
18814                    <div class="field-help-title">Selected artifact preset</div>
18815                    <div class="explainer-body" id="artifact-preset-description"></div>
18816                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
18817                    <div class="code-sample" id="artifact-preset-example"></div>
18818                  </div>
18819                </div>
18820              </div>
18821
18822              <div class="section section-spacer-top">
18823                <div class="output-field-row">
18824                  <div class="field">
18825                    <label for="output_dir">Output directory</label>
18826                    {% if server_mode %}
18827                    <div class="input-group compact">
18828                      <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);" />
18829                    </div>
18830                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18831                    {% else %}
18832                    <div class="input-group compact">
18833                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18834                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18835                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
18836                    </div>
18837                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18838                    {% endif %}
18839                  </div>
18840                  <div class="output-field-aside">
18841                    <strong>Where reports land</strong>
18842                    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.
18843                  </div>
18844                </div>
18845              </div>
18846
18847              <div class="section section-spacer-top">
18848                <div class="output-field-row">
18849                  <div class="field">
18850                    <label for="report_title">Report title</label>
18851                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18852                    <div class="hint">Appears in HTML and PDF output headers.</div>
18853                  </div>
18854                  <div class="output-field-aside">
18855                    <strong>Shown in exported artifacts</strong>
18856                    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.
18857                  </div>
18858                </div>
18859              </div>
18860
18861              <div class="section section-spacer-top">
18862                <div class="output-field-row">
18863                  <div class="field">
18864                    <label for="report_header_footer">Report header / footer</label>
18865                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18866                    <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>
18867                  </div>
18868                  <div class="output-field-aside">
18869                    <strong>Page-level identification</strong>
18870                    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.
18871                  </div>
18872                </div>
18873              </div>
18874
18875              <div class="wizard-actions">
18876                <div class="left">
18877                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18878                </div>
18879                <div class="right">
18880                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18881                </div>
18882              </div>
18883            </div>
18884
18885            <div class="wizard-step" data-step="4">
18886              <div class="section">
18887                <div class="section-kicker">Step 4</div>
18888                <h2>Review selections and run</h2>
18889                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18890                <div class="review-grid">
18891                  <div class="review-card highlight">
18892                    <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>
18893                    <ul id="review-scan-summary"></ul>
18894                  </div>
18895                  <div class="review-card highlight">
18896                    <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>
18897                    <ul id="review-count-summary"></ul>
18898                  </div>
18899                  <div class="review-card">
18900                    <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>
18901                    <ul id="review-artifact-summary"></ul>
18902                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18903                  </div>
18904                  <div class="review-card">
18905                    <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>
18906                    <ul id="review-preview-summary"></ul>
18907                  </div>
18908                </div>
18909              </div>
18910
18911              <div class="wizard-actions">
18912                <div class="left">
18913                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18914                </div>
18915                <div class="right">
18916                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
18917                </div>
18918              </div>
18919            </div>
18920            {% if server_mode %}
18921            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18922            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18923            {% endif %}
18924          </form>
18925        </div>
18926      </section>
18927    </div>
18928  </div>
18929
18930  <script nonce="{{ csp_nonce }}">
18931    (function () {
18932      function startScanPhase() {
18933        var phaseEl = document.getElementById("scan-phase");
18934        if (!phaseEl) return;
18935        var phases = [
18936          "Discovering files...",
18937          "Decoding file encodings...",
18938          "Detecting languages...",
18939          "Analyzing source lines...",
18940          "Applying counting policies...",
18941          "Aggregating results...",
18942          "Rendering report..."
18943        ];
18944        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18945        var i = 0;
18946        function next() {
18947          phaseEl.style.opacity = "0";
18948          setTimeout(function () {
18949            phaseEl.textContent = phases[i];
18950            phaseEl.style.opacity = "0.85";
18951            var delay = durations[i] || 1800;
18952            i++;
18953            if (i < phases.length) { setTimeout(next, delay); }
18954          }, 200);
18955        }
18956        next();
18957      }
18958
18959      var form = document.getElementById("analyze-form");
18960      var loading = document.getElementById("loading");
18961      var submitButton = document.getElementById("submit-button");
18962      var pathInput = document.getElementById("path");
18963      var GIT_MODE = !!(pathInput && pathInput.readOnly);
18964      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18965      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18966      var outputDirInput = document.getElementById("output_dir");
18967      var reportTitleInput = document.getElementById("report_title");
18968      var previewPanel = document.getElementById("preview-panel");
18969      var refreshButton = document.getElementById("refresh-preview");
18970      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18971      var useSamplePath = document.getElementById("use-sample-path");
18972      var useDefaultOutput = document.getElementById("use-default-output");
18973      var browsePath = document.getElementById("browse-path");
18974      var browseOutputDir = document.getElementById("browse-output-dir");
18975      var browseCoverage = document.getElementById("browse-coverage");
18976      var coverageInput = document.getElementById("coverage_file");
18977      var covScanStatus = document.getElementById("cov-scan-status");
18978      var coverageSuggestTimer = null;
18979      var covAutoFilled = false;
18980      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18981
18982      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18983      (function() {
18984        var ids = ["path", "output_dir"];
18985        ids.forEach(function(id) {
18986          var el = document.getElementById(id);
18987          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18988        });
18989      }());
18990      function fmtBytes(b) {
18991        b = Number(b) || 0;
18992        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18993        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18994        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
18995        return b + ' B';
18996      }
18997      var themeToggle = document.getElementById("theme-toggle");
18998
18999      function showBannerToast(msg, isError, opts) {
19000        opts = opts || {};
19001        var t = document.createElement('div');
19002        t.className = isError ? 'toast-error' : 'toast-success';
19003        var topPos = opts.top ? '80px' : null;
19004        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19005          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19006          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19007          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19008        if (opts.icon) {
19009          var inner = document.createElement('span');
19010          inner.innerHTML = opts.icon + ' ';
19011          t.appendChild(inner);
19012        }
19013        t.appendChild(document.createTextNode(msg));
19014        document.body.appendChild(t);
19015        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19016      }
19017      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19018      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19019      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19020      var scanPreset = document.getElementById("scan_preset");
19021      var artifactPreset = document.getElementById("artifact_preset");
19022      var includeGlobsInput = document.getElementById("include_globs");
19023      var excludeGlobsInput = document.getElementById("exclude_globs");
19024
19025      // Include globs scope badge — updates reactively as the user types.
19026      (function() {
19027        var badge = document.getElementById("include-scope-badge");
19028        if (!badge || !includeGlobsInput) return;
19029        var iconCheck = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> ';
19030        var iconFilter = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> ';
19031        function update() {
19032          var val = includeGlobsInput.value.trim();
19033          if (!val) {
19034            badge.className = "include-scope-badge scope-all";
19035            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19036          } else {
19037            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19038            badge.className = "include-scope-badge scope-narrow";
19039            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19040          }
19041        }
19042        includeGlobsInput.addEventListener("input", update);
19043        update();
19044      }());
19045
19046      // Quick-exclude chips — append pattern to exclude_globs textarea.
19047      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19048        chip.addEventListener("click", function() {
19049          var pattern = chip.getAttribute("data-pattern") || "";
19050          if (!pattern || !excludeGlobsInput) return;
19051          var current = excludeGlobsInput.value.trim();
19052          // For the "skip all" chip, replace any existing dep patterns cleanly.
19053          var patterns = pattern.split("\n");
19054          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19055          var added = false;
19056          patterns.forEach(function(p) {
19057            p = p.trim();
19058            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19059          });
19060          if (added) {
19061            excludeGlobsInput.value = lines.join("\n");
19062            excludeGlobsInput.dispatchEvent(new Event("input"));
19063          }
19064          chip.classList.add("active");
19065        });
19066      });
19067
19068      var liveReportTitle = document.getElementById("live-report-title");
19069      var navProjectPill = document.getElementById("nav-project-pill");
19070      var navProjectTitle = document.getElementById("nav-project-title");
19071      var reportTitlePreview = null;
19072      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19073      var wizardProgressValue = document.getElementById("wizard-progress-value");
19074      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19075      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19076      var reportTitleTouched = false;
19077      var currentStep = 1;
19078      var previewTimer = null;
19079      var _previewGen = 0;
19080      // True while the scope preview (local) / project upload (server mode) is in
19081      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19082      // user can't advance past a project whose scope/upload isn't ready yet.
19083      var previewLoading = false;
19084      // Set when the current preview reports multiple independent git repos under
19085      // the selected root. Advancing past step 1 is blocked until the user ticks
19086      // the acknowledgement checkbox (or re-selects a single repository).
19087      var multiRepoBlocked = false;
19088      function step1ForwardBlocked() {
19089        return previewLoading || multiRepoBlocked;
19090      }
19091      function refreshStep1Gate() {
19092        var nextBtn = document.getElementById("step1-next");
19093        if (nextBtn) {
19094          var blocked = step1ForwardBlocked();
19095          nextBtn.classList.toggle("is-blocked", blocked);
19096          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19097        }
19098      }
19099      function setPreviewLoading(loading) {
19100        previewLoading = !!loading;
19101        var gate = document.getElementById("preview-gate-status");
19102        refreshStep1Gate();
19103        if (gate) {
19104          var txt = gate.querySelector(".preview-gate-text");
19105          if (txt) txt.textContent = SERVER_MODE
19106            ? "Uploading & scanning project…"
19107            : "Scanning project scope…";
19108          gate.style.display = previewLoading ? "flex" : "none";
19109        }
19110      }
19111      // Info button on the gate: scroll up to the live scope preview so the user
19112      // can see exactly what is being scanned (elapsed time + rotating status).
19113      var previewGateInfo = document.getElementById("preview-gate-info");
19114      if (previewGateInfo) {
19115        previewGateInfo.addEventListener("click", function () {
19116          var target = document.getElementById("preview-panel");
19117          if (!target) return;
19118          target.scrollIntoView({ behavior: "smooth", block: "center" });
19119          target.classList.add("preview-panel-flash");
19120          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19121        });
19122      }
19123      var quickScanBtn = document.getElementById("quick-scan-btn");
19124
19125      function dismissAnalysisModal() {
19126        if (loading) loading.classList.remove("active");
19127        document.body.classList.remove("modal-open");
19128        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19129          var el = document.getElementById(id);
19130          if (el) el.classList.add("hidden");
19131        });
19132        var cancelBtn = document.getElementById("lc-cancel-btn");
19133        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19134        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19135        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19136        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19137        for (var ri=1;ri<=4;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
19138        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19139        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19140        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19141        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19142        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19143        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19144      }
19145
19146      var lcDismissBtn = document.getElementById("lc-dismiss");
19147      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19148
19149      // When the browser restores this page from bfcache (Back button after navigating to results),
19150      // the loading overlay would still be showing its active state. Dismiss it immediately.
19151      window.addEventListener("pageshow", function(e) {
19152        if (e.persisted) { dismissAnalysisModal(); }
19153      });
19154
19155      function startAsyncAnalysis(formData) {
19156        var gitRepo = (formData.get("git_repo") || "").toString();
19157        var gitRef  = (formData.get("git_ref")  || "").toString();
19158        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19159        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19160
19161        var pathEl = document.getElementById("lc-path-text");
19162        if (pathEl) pathEl.textContent = displayPath;
19163
19164        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19165          var el = document.getElementById(id);
19166          if (el) el.classList.add("hidden");
19167        });
19168        var cancelBtn = document.getElementById("lc-cancel-btn");
19169        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19170        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19171        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19172        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19173        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19174        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19175        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19176        for (var si=1;si<=4;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
19177        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19178
19179        if (loading) loading.classList.add("active");
19180        document.body.classList.add("modal-open");
19181
19182        var startTime = Date.now();
19183        var elapsedTimer = setInterval(function() {
19184          var s = Math.floor((Date.now() - startTime) / 1000);
19185          var el = document.getElementById("lc-elapsed");
19186          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19187        }, 1000);
19188
19189        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19190
19191        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();}
19192
19193        var PHASE_DESC = {
19194          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19195          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19196          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19197          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19198          'Done': 'Analysis complete \u2014 loading your results\u2026',
19199          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19200        };
19201        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19202        function lcSetPhase(txt) {
19203          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19204          var desc = document.getElementById("lc-stage-desc");
19205          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19206          var step = PHASE_STEP[txt] || 1;
19207          for (var i=1;i<=4;i++){var s=document.getElementById("lc-step-"+i);if(!s)continue;s.classList.remove("active","done");if(i<step)s.classList.add("done");else if(i===step)s.classList.add("active");}
19208        }
19209
19210        function lcShowCancelled() {
19211          clearInterval(elapsedTimer);
19212          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19213          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19214          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19215          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19216          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19217          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19218          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19219          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19220          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19221          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19222        }
19223
19224        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19225        if (lcCancelBtn) {
19226          lcCancelBtn.onclick = function() {
19227            if (!activeWaitId) { dismissAnalysisModal(); return; }
19228            lcCancelBtn.disabled = true;
19229            lcCancelBtn.textContent = "Cancelling\u2026";
19230            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19231              .then(function() { lcShowCancelled(); })
19232              .catch(function() { lcShowCancelled(); });
19233          };
19234        }
19235
19236        function lcShowError(msg) {
19237          clearInterval(elapsedTimer);
19238          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19239          lcSetPhase("Failed");
19240          var msgEl = document.getElementById("lc-err-msg");
19241          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19242          var errEl = document.getElementById("lc-err");
19243          var actEl = document.getElementById("lc-actions");
19244          if (errEl) errEl.classList.remove("hidden");
19245          if (actEl) actEl.classList.remove("hidden");
19246          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19247          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19248        }
19249
19250        function lcPoll(waitId) {
19251          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19252            .then(function(r) {
19253              if (!r.ok) throw new Error("HTTP " + r.status);
19254              return r.json();
19255            })
19256            .then(function(data) {
19257              pollRetries = 0;
19258              if (data.state === "complete") {
19259                clearInterval(elapsedTimer);
19260                lcSetPhase("Done");
19261                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19262              } else if (data.state === "failed") {
19263                lcShowError(data.message);
19264              } else if (data.state === "cancelled") {
19265                lcShowCancelled();
19266              } else {
19267                var s = Math.floor((Date.now() - startTime) / 1000);
19268                if (s > 90 && !warnShown) {
19269                  warnShown = true;
19270                  var w = document.getElementById("lc-warn");
19271                  if (w) w.classList.remove("hidden");
19272                }
19273                lcSetPhase(data.phase || "Running");
19274                var fd = data.files_done || 0, ft = data.files_total || 0;
19275                if (ft > 0) {
19276                  var card = document.getElementById("lc-files-card");
19277                  if (card) card.classList.remove("hidden");
19278                  var el = document.getElementById("lc-files");
19279                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19280                  var now = Date.now();
19281                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19282                  if (fdelta > 0 && tdelta > 0.4) {
19283                    var fps = Math.round(fdelta / tdelta);
19284                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19285                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19286                  }
19287                  lastFd = fd; lastFdTime = now;
19288                }
19289                setTimeout(function() { lcPoll(waitId); }, 1500);
19290              }
19291            })
19292            .catch(function() {
19293              pollRetries++;
19294              if (pollRetries >= 5) {
19295                lcShowError("Lost connection to server. Reload to check status.");
19296              } else {
19297                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19298              }
19299            });
19300        }
19301
19302        var params = new URLSearchParams(formData);
19303        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19304          .then(function(r) {
19305            var waitId = r.headers.get("x-wait-id");
19306            if (!waitId) { window.location.href = "/scan"; return; }
19307            activeWaitId = waitId;
19308            setTimeout(function() { lcPoll(waitId); }, 1500);
19309          })
19310          .catch(function(err) {
19311            lcShowError("Could not reach server: " + (err.message || err));
19312          });
19313      }
19314
19315      if (quickScanBtn) {
19316        quickScanBtn.addEventListener("click", function () {
19317          var pathVal = pathInput ? pathInput.value.trim() : "";
19318          if (!pathVal) {
19319            alert("Please enter or browse to a project path first.");
19320            return;
19321          }
19322          quickScanBtn.disabled = true;
19323          quickScanBtn.textContent = "Scanning...";
19324          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19325          startAsyncAnalysis(new FormData(form));
19326        });
19327      }
19328
19329      var mixedPolicyInfo = {
19330        code_only: {
19331          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.",
19332          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'
19333        },
19334        code_and_comment: {
19335          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.",
19336          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'
19337        },
19338        comment_only: {
19339          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.",
19340          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'
19341        },
19342        separate_mixed_category: {
19343          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.",
19344          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'
19345        }
19346      };
19347
19348      var scanPresetInfo = {
19349        balanced: {
19350          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.",
19351          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19352          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19353          note: "Best when you want a stable local overview before making deeper adjustments.",
19354          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19355        },
19356        code_focused: {
19357          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19358          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19359          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19360          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19361          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19362        },
19363        comment_audit: {
19364          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19365          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19366          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19367          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19368          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19369        },
19370        deep_review: {
19371          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19372          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19373          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19374          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19375          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19376        }
19377      };
19378
19379      var artifactPresetInfo = {
19380        review: {
19381          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19382          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19383          example: "Ideal for a quick local review before sharing results."
19384        },
19385        full: {
19386          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19387          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19388          example: "Use when producing a deliverable or storing a snapshot for future comparison."
19389        },
19390        html_only: {
19391          description: "Standalone HTML report only. No PDF generation, no data files.",
19392          chips: ["HTML only"],
19393          example: "Fastest option when you only need to open the report in a browser."
19394        },
19395        machine: {
19396          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19397          chips: ["JSON", "CSV", "no HTML", "no PDF"],
19398          example: "Use in CI to capture metrics without generating visual reports."
19399        }
19400      };
19401
19402      function applyArtifactPreset() {
19403        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19404        if (!info) return;
19405        var descEl = document.getElementById("artifact-preset-description");
19406        var exampleEl = document.getElementById("artifact-preset-example");
19407        if (descEl) descEl.textContent = info.description;
19408        if (exampleEl) exampleEl.textContent = info.example;
19409        renderPresetChips("artifact-preset-summary", info.chips);
19410      }
19411
19412      function applyTheme(theme) {
19413        if (theme === "dark") document.body.classList.add("dark-theme");
19414        else document.body.classList.remove("dark-theme");
19415      }
19416
19417      function loadSavedTheme() {
19418        var saved = null;
19419        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19420        applyTheme(saved === "dark" ? "dark" : "light");
19421      }
19422
19423      function updateScrollProgress() {
19424        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19425        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19426        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19427        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19428        var step = Math.min(Math.max(currentStep, 1), 4);
19429        var base = stepBase[step];
19430        var end  = stepEnd[step];
19431
19432        var scrollFrac = 0;
19433        var activePanel = document.querySelector(".wizard-step.active");
19434        if (activePanel) {
19435          var scrollTop = window.scrollY || window.pageYOffset || 0;
19436          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19437          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19438          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19439          var scrolled = scrollTop + viewH - panelTop;
19440          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19441        }
19442
19443        var percent = Math.round(base + (end - base) * scrollFrac);
19444        percent = Math.min(end, Math.max(base, percent));
19445        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19446        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19447      }
19448
19449      function updateWizardProgress() {
19450        updateScrollProgress();
19451      }
19452
19453      var stepDescriptions = [
19454        "Choose a project folder, apply scope filters, and preview which files will be counted.",
19455        "Configure how mixed code-plus-comment lines and docstrings are classified.",
19456        "Pick your output formats, scan preset, and where reports are saved.",
19457        "Review all settings and launch the analysis."
19458      ];
19459
19460      function updateStepNav(step) {
19461        var infoLabel = document.getElementById("step-nav-info-label");
19462        var infoDesc  = document.getElementById("step-nav-info-desc");
19463        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19464        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
19465      }
19466
19467      function updateSidebarSummary() {
19468        var sumPath    = document.getElementById("sum-path");
19469        var sumPreset  = document.getElementById("sum-preset");
19470        var sumOutput  = document.getElementById("sum-output");
19471        var sidebarSummary = document.getElementById("sidebar-summary");
19472        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19473        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
19474        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19475        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
19476        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19477        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19478        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19479      }
19480
19481      function setStep(step, pushHistory) {
19482        currentStep = step;
19483        stepPanels.forEach(function (panel) {
19484          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19485        });
19486        stepButtons.forEach(function (button) {
19487          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19488        });
19489        var layoutEl = document.querySelector(".layout");
19490        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19491        updateWizardProgress();
19492        updateStepNav(step);
19493        stepButtons.forEach(function(btn) {
19494          var t = Number(btn.getAttribute("data-step-target"));
19495          btn.classList.toggle("done", t < step);
19496        });
19497        updateSidebarSummary();
19498
19499        if (pushHistory !== false) {
19500          try {
19501            history.pushState({ wizardStep: step }, "", "#step" + step);
19502          } catch (e) {}
19503        }
19504
19505        window.scrollTo({ top: 0, behavior: "instant" });
19506      }
19507
19508      window.addEventListener("popstate", function (e) {
19509        if (e.state && e.state.wizardStep) {
19510          setStep(e.state.wizardStep, false);
19511        } else {
19512          var hashMatch = location.hash.match(/^#step([1-4])$/);
19513          if (hashMatch) setStep(Number(hashMatch[1]), false);
19514        }
19515      });
19516
19517      function inferTitleFromPath(value) {
19518        if (!value) return "project";
19519        var cleaned = value.replace(/[\/\\]+$/, "");
19520        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19521        return parts.length ? parts[parts.length - 1] : value;
19522      }
19523
19524      function updateReportTitleFromPath() {
19525        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19526        if (!reportTitleTouched) {
19527          reportTitleInput.value = inferred;
19528        }
19529        var title = reportTitleInput.value || inferred;
19530        if (liveReportTitle) liveReportTitle.textContent = title;
19531        if (reportTitlePreview) reportTitlePreview.textContent = title;
19532        document.title = "OxideSLOC | " + title;
19533
19534        var projectPath = (pathInput.value || "").trim();
19535        if (navProjectPill && navProjectTitle) {
19536          if (projectPath.length > 0) {
19537            navProjectTitle.textContent = inferred;
19538            navProjectPill.classList.add("visible");
19539          } else {
19540            navProjectTitle.textContent = "";
19541            navProjectPill.classList.remove("visible");
19542          }
19543        }
19544      }
19545
19546      function updateMixedPolicyUI() {
19547        var key = mixedLinePolicy.value || "code_only";
19548        var info = mixedPolicyInfo[key];
19549        document.getElementById("mixed-policy-description").textContent = info.description;
19550        document.getElementById("mixed-policy-example").textContent = info.example;
19551      }
19552
19553      function updatePythonDocstringUI() {
19554        var checked = !!pythonDocstrings.checked;
19555        document.getElementById("python-docstring-example").textContent = checked
19556          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
19557          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
19558        document.getElementById("python-docstring-live-help").textContent = checked
19559          ? "Enabled: docstrings contribute to comment-style totals."
19560          : "Disabled: docstrings are not counted as comment content.";
19561      }
19562
19563      function renderPresetChips(targetId, chips) {
19564        var target = document.getElementById(targetId);
19565        if (!target) return;
19566        target.innerHTML = (chips || []).map(function (chip) {
19567          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19568        }).join('');
19569      }
19570
19571      function updatePresetDescriptions() {
19572        var scanInfo = scanPresetInfo[scanPreset.value];
19573        if (!scanInfo) return;
19574        document.getElementById("scan-preset-description").textContent = scanInfo.description;
19575        document.getElementById("scan-preset-example").textContent = scanInfo.example;
19576        document.getElementById("scan-preset-note").textContent = scanInfo.note;
19577        renderPresetChips("scan-preset-summary", scanInfo.chips);
19578      }
19579
19580      function applyScanPreset() {
19581        var info = scanPresetInfo[scanPreset.value];
19582        if (!info || !info.apply) return;
19583        mixedLinePolicy.value = info.apply.mixed;
19584        pythonDocstrings.checked = !!info.apply.docstrings;
19585        document.getElementById("generated_file_detection").value = info.apply.generated;
19586        document.getElementById("minified_file_detection").value = info.apply.minified;
19587        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19588        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19589        document.getElementById("binary_file_behavior").value = info.apply.binary;
19590        updateMixedPolicyUI();
19591        updatePythonDocstringUI();
19592      }
19593
19594      function updateReview() {
19595        var scanSummary = document.getElementById("review-scan-summary");
19596        var countSummary = document.getElementById("review-count-summary");
19597        var artifactSummary = document.getElementById("review-artifact-summary");
19598        var outputSummary = document.getElementById("review-output-summary");
19599        var previewSummary = document.getElementById("review-preview-summary");
19600        var readinessSummary = document.getElementById("review-readiness-summary");
19601        var includeText = document.getElementById("include_globs").value.trim();
19602        var excludeText = document.getElementById("exclude_globs").value.trim();
19603        var sidePathPreview = document.getElementById("side-path-preview");
19604        var sideOutputPreview = document.getElementById("side-output-preview");
19605        var sideTitlePreview = document.getElementById("side-title-preview");
19606
19607        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19608        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19609        if (sideTitlePreview) {
19610          var rt = document.getElementById("report_title");
19611          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19612        }
19613
19614        scanSummary.innerHTML = ""
19615          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19616          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19617          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19618
19619        countSummary.innerHTML = ""
19620          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19621          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19622          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19623          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19624          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19625          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19626          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19627          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19628
19629        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19630
19631        outputSummary.innerHTML = ""
19632          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19633          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19634
19635        if (previewSummary) {
19636          if (GIT_MODE) {
19637            previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
19638          } else {
19639          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19640          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19641          var statMap = {};
19642          statButtons.forEach(function (button) {
19643            var valueNode = button.querySelector('.scope-stat-value');
19644            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19645          });
19646          previewSummary.innerHTML = ''
19647            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19648            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19649            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19650            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19651            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19652            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19653
19654          if (readinessSummary) {
19655            readinessSummary.innerHTML = ''
19656              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19657              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19658              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19659          }
19660          } // end else (non-GIT_MODE)
19661        }
19662      }
19663
19664      function escapeHtml(value) {
19665        return String(value)
19666          .replace(/&/g, "&amp;")
19667          .replace(/</g, "&lt;")
19668          .replace(/>/g, "&gt;")
19669          .replace(/"/g, "&quot;")
19670          .replace(/'/g, "&#39;");
19671      }
19672
19673      function isPythonVisible() {
19674        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19675      }
19676
19677      function syncPythonVisibility() {
19678        var html = previewPanel.textContent || "";
19679        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19680        pythonWraps.forEach(function (node) {
19681          node.classList.toggle("hidden", !hasPython);
19682        });
19683      }
19684
19685      function attachPreviewInteractions() {
19686        // Multiple-repository caution banner: gate step 1 until acknowledged, and
19687        // let each listed repo be picked as the scan root with one click.
19688        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
19689        if (multiRepoBanner) {
19690          multiRepoBlocked = true;
19691          refreshStep1Gate();
19692          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
19693          if (ackBox) {
19694            ackBox.addEventListener("change", function () {
19695              multiRepoBlocked = !ackBox.checked;
19696              refreshStep1Gate();
19697            });
19698          }
19699          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
19700          repoButtons.forEach(function (btn) {
19701            btn.addEventListener("click", function () {
19702              var repoPath = btn.getAttribute("data-repo-path") || "";
19703              if (!repoPath || !pathInput) return;
19704              pathInput.value = repoPath;
19705              scrollInputToEnd(pathInput);
19706              updateReportTitleFromPath();
19707              autoSetOutputDir(repoPath);
19708              fetchProjectHistory(repoPath);
19709              loadPreview();
19710              updateReview();
19711            });
19712          });
19713        }
19714        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19715        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19716        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19717        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19718        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19719        var searchInput = previewPanel.querySelector("#explorer-search");
19720        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19721        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19722        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19723        var activeFilter = "all";
19724        var activeLanguage = "";
19725        var searchTerm = "";
19726        var currentSortKey = null;
19727        var currentSortOrder = "asc";
19728        var childRows = {};
19729
19730        rows.forEach(function (row) {
19731          var parentId = row.getAttribute("data-parent-id") || "";
19732          var rowId = row.getAttribute("data-row-id") || "";
19733          if (!childRows[parentId]) childRows[parentId] = [];
19734          childRows[parentId].push(rowId);
19735        });
19736
19737        function rowById(id) {
19738          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19739        }
19740
19741        function hasCollapsedAncestor(row) {
19742          var parentId = row.getAttribute("data-parent-id");
19743          while (parentId) {
19744            var parent = rowById(parentId);
19745            if (!parent) break;
19746            if (parent.getAttribute("data-expanded") === "false") return true;
19747            parentId = parent.getAttribute("data-parent-id");
19748          }
19749          return false;
19750        }
19751
19752        function updateToggleGlyph(row) {
19753          var toggle = row.querySelector(".tree-toggle");
19754          if (!toggle) return;
19755          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19756        }
19757
19758        function rowSortValue(row, key) {
19759          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19760        }
19761
19762        function updateSortButtons() {
19763          sortButtons.forEach(function (button) {
19764            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19765            var indicator = button.querySelector(".tree-sort-indicator");
19766            button.classList.toggle("active", isActive);
19767            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19768            if (indicator) {
19769              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19770            }
19771          });
19772        }
19773
19774        function sortSiblingRows() {
19775          if (!treeContainer) {
19776            updateSortButtons();
19777            return;
19778          }
19779
19780          var rowMap = {};
19781          var childrenMap = {};
19782          rows.forEach(function (row) {
19783            var rowId = row.getAttribute("data-row-id");
19784            var parentId = row.getAttribute("data-parent-id") || "";
19785            rowMap[rowId] = row;
19786            if (!childrenMap[parentId]) childrenMap[parentId] = [];
19787            childrenMap[parentId].push(rowId);
19788          });
19789
19790          Object.keys(childrenMap).forEach(function (parentId) {
19791            if (!parentId) return;
19792            childrenMap[parentId].sort(function (a, b) {
19793              var rowA = rowMap[a];
19794              var rowB = rowMap[b];
19795              if (!currentSortKey) {
19796                return Number(a) - Number(b);
19797              }
19798              var valueA = rowSortValue(rowA, currentSortKey);
19799              var valueB = rowSortValue(rowB, currentSortKey);
19800              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19801              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19802              var fallbackA = rowSortValue(rowA, "name");
19803              var fallbackB = rowSortValue(rowB, "name");
19804              if (fallbackA < fallbackB) return -1;
19805              if (fallbackA > fallbackB) return 1;
19806              return Number(a) - Number(b);
19807            });
19808          });
19809
19810          var orderedIds = [];
19811          function pushChildren(parentId) {
19812            (childrenMap[parentId] || []).forEach(function (childId) {
19813              orderedIds.push(childId);
19814              pushChildren(childId);
19815            });
19816          }
19817
19818          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19819            orderedIds.push(topId);
19820            pushChildren(topId);
19821          });
19822
19823          orderedIds.forEach(function (id) {
19824            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19825          });
19826          updateSortButtons();
19827        }
19828
19829        function updateLanguageButtons() {
19830          languageButtons.forEach(function (button) {
19831            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19832            var isActive = languageValue === activeLanguage;
19833            button.classList.toggle("active", isActive);
19834          });
19835        }
19836
19837        function rowSelfMatches(row) {
19838          var kind = row.getAttribute("data-kind");
19839          var status = row.getAttribute("data-status");
19840          var language = (row.getAttribute("data-language") || "").toLowerCase();
19841          var name = row.getAttribute("data-name-lower") || "";
19842          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19843          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19844          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19845          var passesLanguage = !activeLanguage || language === activeLanguage;
19846          return passesFilter && passesSearch && passesLanguage;
19847        }
19848
19849        function hasMatchingDescendant(rowId) {
19850          return (childRows[rowId] || []).some(function (childId) {
19851            var childRow = rowById(childId);
19852            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19853          });
19854        }
19855
19856        function rowMatches(row) {
19857          if (rowSelfMatches(row)) return true;
19858          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19859        }
19860
19861        function resetViewState() {
19862          activeFilter = "all";
19863          activeLanguage = "";
19864          searchTerm = "";
19865          currentSortKey = null;
19866          currentSortOrder = "asc";
19867          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19868          if (searchInput) searchInput.value = "";
19869          if (filterSelect) filterSelect.value = "all";
19870          updateLanguageButtons();
19871        }
19872
19873        function applyVisibility() {
19874          rows.forEach(function (row) {
19875            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19876            row.classList.toggle("hidden-by-filter", !visible);
19877            row.style.display = visible ? "grid" : "none";
19878          });
19879          buttons.forEach(function (button) {
19880            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19881          });
19882          if (filterSelect) filterSelect.value = activeFilter;
19883        }
19884
19885        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19886        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19887        var originalStats = {};
19888        buttons.forEach(function (btn) {
19889          var f = btn.getAttribute('data-filter');
19890          var v = btn.querySelector('.scope-stat-value');
19891          if (f && v) originalStats[f] = v.textContent;
19892        });
19893
19894        function applySubmoduleStats(statsJson) {
19895          try {
19896            var s = JSON.parse(statsJson);
19897            buttons.forEach(function (btn) {
19898              var f = btn.getAttribute('data-filter');
19899              var v = btn.querySelector('.scope-stat-value');
19900              if (!v) return;
19901              if (f === 'dir') v.textContent = s.dirs;
19902              else if (f === 'file') v.textContent = s.files;
19903              else if (f === 'supported') v.textContent = s.supported;
19904              else if (f === 'skipped') v.textContent = s.skipped;
19905              else if (f === 'unsupported') v.textContent = s.unsupported;
19906            });
19907          } catch (e) {}
19908        }
19909
19910        function restoreBaseRepoStats() {
19911          buttons.forEach(function (btn) {
19912            var f = btn.getAttribute('data-filter');
19913            var v = btn.querySelector('.scope-stat-value');
19914            if (v && originalStats[f]) v.textContent = originalStats[f];
19915          });
19916          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19917          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19918        }
19919
19920        submoduleChips.forEach(function (chip) {
19921          chip.addEventListener('click', function () {
19922            var statsJson = chip.getAttribute('data-sub-stats');
19923            if (!statsJson) return;
19924            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19925            chip.classList.add('active');
19926            applySubmoduleStats(statsJson);
19927            if (baseRepoBtn) baseRepoBtn.style.display = '';
19928          });
19929        });
19930
19931        if (baseRepoBtn) {
19932          baseRepoBtn.addEventListener('click', function () {
19933            restoreBaseRepoStats();
19934            resetViewState();
19935            sortSiblingRows();
19936            applyVisibility();
19937          });
19938        }
19939
19940        buttons.forEach(function (button) {
19941          button.addEventListener("click", function () {
19942            var filterValue = button.getAttribute("data-filter") || "all";
19943            if (filterValue === "reset-view") {
19944              restoreBaseRepoStats();
19945              resetViewState();
19946              sortSiblingRows();
19947              applyVisibility();
19948              return;
19949            }
19950            activeFilter = filterValue;
19951            applyVisibility();
19952          });
19953        });
19954
19955        rows.forEach(function (row) {
19956          updateToggleGlyph(row);
19957          var toggle = row.querySelector(".tree-toggle");
19958          if (toggle) {
19959            toggle.addEventListener("click", function () {
19960              var expanded = row.getAttribute("data-expanded") !== "false";
19961              row.setAttribute("data-expanded", expanded ? "false" : "true");
19962              updateToggleGlyph(row);
19963              applyVisibility();
19964            });
19965          }
19966        });
19967
19968        actionButtons.forEach(function (button) {
19969          button.addEventListener("click", function () {
19970            var action = button.getAttribute("data-explorer-action");
19971            if (action === "expand-all") {
19972              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19973            } else if (action === "collapse-all") {
19974              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19975            } else if (action === "clear-filters") {
19976              resetViewState();
19977            }
19978            sortSiblingRows();
19979            applyVisibility();
19980          });
19981        });
19982
19983        if (filterSelect) {
19984          filterSelect.addEventListener("change", function () {
19985            activeFilter = filterSelect.value || "all";
19986            applyVisibility();
19987          });
19988        }
19989
19990        languageButtons.forEach(function (button) {
19991          button.addEventListener("click", function () {
19992            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19993            updateLanguageButtons();
19994            applyVisibility();
19995          });
19996        });
19997
19998        sortButtons.forEach(function (button) {
19999          button.addEventListener("click", function () {
20000            var sortKey = button.getAttribute("data-sort-key");
20001            if (currentSortKey === sortKey) {
20002              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20003            } else {
20004              currentSortKey = sortKey;
20005              currentSortOrder = "asc";
20006            }
20007            sortSiblingRows();
20008            applyVisibility();
20009          });
20010        });
20011
20012        if (searchInput) {
20013          searchInput.addEventListener("input", function () {
20014            searchTerm = searchInput.value.trim().toLowerCase();
20015            applyVisibility();
20016          });
20017        }
20018
20019        updateLanguageButtons();
20020        sortSiblingRows();
20021        applyVisibility();
20022      }
20023
20024      function loadPreview() {
20025        if (!previewPanel || !pathInput) return;
20026        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20027        multiRepoBlocked = false;
20028        refreshStep1Gate();
20029        if (GIT_MODE) {
20030          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>';
20031          setPreviewLoading(false);
20032          return;
20033        }
20034        var path = pathInput.value.trim();
20035        var zeroWarn = document.getElementById('zero-files-warning');
20036        if (!path) {
20037          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20038          if (zeroWarn) zeroWarn.style.display = 'none';
20039          setPreviewLoading(false);
20040          return;
20041        }
20042        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20043        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20044        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20045        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20046        var myGen = ++_previewGen;
20047        var _prevMsgs = [
20048          'Scanning directory structure\u2026',
20049          'Detecting file types\u2026',
20050          'Applying include / exclude filters\u2026',
20051          'Estimating file counts\u2026',
20052          'Building scope preview\u2026',
20053          'Almost there\u2026'
20054        ];
20055        var _prevMsgIdx = 0;
20056        var _prevStart = Date.now();
20057        previewPanel.innerHTML =
20058          '<div class="preview-loading">' +
20059          '<div class="preview-spinner"></div>' +
20060          '<div class="preview-loading-text">' +
20061          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20062          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20063          '</div></div>';
20064        var _sizeTextEl = document.getElementById('project-size-text');
20065        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20066        window._previewInterval = setInterval(function() {
20067          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20068          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20069          var ml = document.getElementById('plm');
20070          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20071        }, 1500);
20072        window._previewElapsedTimer = setInterval(function() {
20073          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20074          var el = document.getElementById('ple');
20075          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20076        }, 1000);
20077        setPreviewLoading(true);
20078        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20079          + "&include_globs=" + encodeURIComponent(includeValue)
20080          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20081        fetch(previewUrl)
20082          .then(function (response) { return response.text(); })
20083          .then(function (html) {
20084            if (myGen !== _previewGen) return;
20085            clearInterval(window._previewInterval); window._previewInterval = null;
20086            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20087            setPreviewLoading(false);
20088            previewPanel.innerHTML = html;
20089            attachPreviewInteractions();
20090            syncPythonVisibility();
20091            updateReview();
20092            setTimeout(collapseLanguagePills, 50);
20093            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20094            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20095            var sizeText = document.getElementById('project-size-text');
20096            var sizeBtn = document.getElementById('project-size-btn');
20097            // In server mode with upload sizes available, keep the compressed/original pair.
20098            if (SERVER_MODE && window._lastUploadSizes) {
20099              var us = window._lastUploadSizes;
20100              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20101                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20102              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20103                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20104            } else if (sizeText && projectSize) {
20105              sizeText.textContent = 'Project size: ' + projectSize;
20106              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20107            } else if (sizeText) {
20108              sizeText.textContent = 'Project size: \u2014';
20109            }
20110            if (zeroWarn) {
20111              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20112              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20113              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20114              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20115              if (supportedCount === 0 && fileCount > 0) {
20116                zeroWarn.textContent = '\u26a0 Warning: No supported source files detected\u2014this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
20117                zeroWarn.style.display = '';
20118              } else {
20119                zeroWarn.style.display = 'none';
20120              }
20121            }
20122          })
20123          .catch(function (err) {
20124            if (myGen !== _previewGen) return;
20125            clearInterval(window._previewInterval); window._previewInterval = null;
20126            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20127            setPreviewLoading(false);
20128            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20129          });
20130      }
20131
20132      function pickDirectory(targetInput, kind) {
20133        if (!targetInput) {
20134          showBannerToast("Directory picker: input element not found.", true);
20135          return;
20136        }
20137        if (SERVER_MODE) {
20138          if (kind === 'output') {
20139            showBannerToast(
20140              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20141              false,
20142              { top: true, icon: '\u{1F4C1}' }
20143            );
20144            return;
20145          }
20146          var inputEl = kind === 'coverage'
20147            ? document.getElementById('cov-upload-input')
20148            : document.getElementById('dir-upload-input');
20149          if (!inputEl) return;
20150          inputEl.onchange = function () {
20151            var files = inputEl.files;
20152            if (!files || files.length === 0) return;
20153            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20154            if (browseBtn) browseBtn.disabled = true;
20155
20156            function fileToBase64(file) {
20157              return new Promise(function (resolve, reject) {
20158                var reader = new FileReader();
20159                reader.onload = function () {
20160                  var b64 = reader.result.split(',')[1];
20161                  resolve(b64);
20162                };
20163                reader.onerror = reject;
20164                reader.readAsDataURL(file);
20165              });
20166            }
20167
20168            if (kind === 'coverage') {
20169              var f = files[0];
20170              if (previewPanel && targetInput === pathInput)
20171                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20172              fileToBase64(f).then(function (b64) {
20173                return fetch('/api/upload-file', {
20174                  method: 'POST',
20175                  headers: { 'Content-Type': 'application/json' },
20176                  body: JSON.stringify({ filename: f.name, content: b64 })
20177                }).then(function (r) { return r.json(); });
20178              })
20179                .then(function (d) {
20180                  if (d && d.tmp_path) {
20181                    if (coverageInput) coverageInput.value = d.tmp_path;
20182                    setCovStatus('idle');
20183                  } else if (d && d.error) { showBannerToast(d.error, true); }
20184                })
20185                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20186                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20187            } else {
20188              // ── Filter to source-code files only ─────────────────────────
20189              // Binary, generated, and dependency files (node_modules, .git,
20190              // build artifacts) are skipped so they are never uploaded.
20191              var CODE_EXTS = new Set([
20192                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20193                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20194                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20195                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20196                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20197                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20198                'tf','hcl','proto','thrift','avsc','graphql','gql'
20199              ]);
20200              var codeFiles = [];
20201              for (var i = 0; i < files.length; i++) {
20202                var f = files[i];
20203                var name = f.name;
20204                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20205                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20206                  codeFiles.push(f); continue;
20207                }
20208                var dot = name.lastIndexOf('.');
20209                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20210              }
20211              // Collect specific .git metadata files for server-side git detection.
20212              // These have no source extension so they are excluded by the loop above,
20213              // but the server needs them to read branch/commit/author without running git.
20214              var gitMetaFiles = [];
20215              for (var i = 0; i < files.length; i++) {
20216                var f = files[i];
20217                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20218                var gitIdx = rp.indexOf('/.git/');
20219                if (gitIdx < 0) continue;
20220                var gitRel = rp.slice(gitIdx + 1);
20221                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20222                    gitRel === '.git/logs/HEAD' ||
20223                    gitRel.startsWith('.git/refs/heads/') ||
20224                    gitRel.startsWith('.git/refs/tags/')) {
20225                  gitMetaFiles.push(f);
20226                }
20227              }
20228              var uploadFiles = codeFiles.concat(gitMetaFiles);
20229              var total = files.length;
20230              var kept = codeFiles.length;
20231              if (kept === 0) {
20232                if (previewPanel && targetInput === pathInput)
20233                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20234                if (browseBtn) browseBtn.disabled = false;
20235                inputEl.value = '';
20236                return;
20237              }
20238
20239              // ── Helper: apply upload result to UI ────────────────────────
20240              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20241              function applyUploadResult(tmpPath, sizes) {
20242                targetInput.value = tmpPath;
20243                scrollInputToEnd(targetInput);
20244                if (sizes && SERVER_MODE) {
20245                  window._lastUploadSizes = sizes;
20246                  // Immediately show both sizes before preview loads.
20247                  var sizeText = document.getElementById('project-size-text');
20248                  var sizeBtn = document.getElementById('project-size-btn');
20249                  if (sizeText) {
20250                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20251                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20252                  }
20253                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20254                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20255                }
20256                if (targetInput === pathInput) {
20257                  updateReportTitleFromPath();
20258                  autoSetOutputDir(tmpPath);
20259                  fetchProjectHistory(tmpPath);
20260                  loadPreview();
20261                  suggestCoverageFile(tmpPath);
20262                }
20263                updateReview();
20264                if (browseBtn) browseBtn.disabled = false;
20265                inputEl.value = '';
20266              }
20267
20268              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20269              if (typeof CompressionStream !== 'undefined') {
20270                if (previewPanel && targetInput === pathInput)
20271                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20272
20273                // Build a minimal POSIX ustar tar header for a single file entry.
20274                function buildUstarHeader(filePath, fileSize) {
20275                  var BLOCK = 512;
20276                  var hdr = new Uint8Array(BLOCK);
20277                  var enc = new TextEncoder();
20278                  function wStr(off, len, s) {
20279                    var b = enc.encode(s);
20280                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20281                  }
20282                  function wOct(off, len, val) {
20283                    var s = val.toString(8);
20284                    while (s.length < len - 1) s = '0' + s;
20285                    wStr(off, len, s + '\0');
20286                  }
20287                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20288                  var name = filePath, prefix = '';
20289                  if (filePath.length > 99) {
20290                    var split = filePath.lastIndexOf('/', 154);
20291                    if (split > 0 && filePath.length - split - 1 <= 99) {
20292                      prefix = filePath.substring(0, split);
20293                      name   = filePath.substring(split + 1);
20294                    } else { name = filePath.substring(0, 99); }
20295                  }
20296                  wStr(0,   100, name);          // name
20297                  wOct(100,   8, 0o000644);      // mode
20298                  wOct(108,   8, 0);             // uid
20299                  wOct(116,   8, 0);             // gid
20300                  wOct(124,  12, fileSize);      // size
20301                  wOct(136,  12, 0);             // mtime (epoch)
20302                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20303                  hdr[156] = 48;                 // type flag '0' = regular file
20304                  wStr(157, 100, '');            // linkname
20305                  wStr(257,   6, 'ustar');       // magic
20306                  wStr(263,   2, '00');          // version
20307                  wStr(265,  32, '');            // uname
20308                  wStr(297,  32, '');            // gname
20309                  wOct(329,   8, 0);             // devmajor
20310                  wOct(337,   8, 0);             // devminor
20311                  wStr(345, 155, prefix);        // prefix
20312                  // Compute checksum (sum of all bytes, placeholder = 32).
20313                  var chk = 0;
20314                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20315                  var cs = chk.toString(8);
20316                  while (cs.length < 6) cs = '0' + cs;
20317                  wStr(148, 8, cs + '\0 ');
20318                  return hdr;
20319                }
20320
20321                // Build tar.gz one file at a time, piping through CompressionStream.
20322                // RAM usage = compressed output buffer + one file at a time.
20323                (async function () {
20324                  try {
20325                    var BLOCK = 512;
20326                    var cs     = new CompressionStream('gzip');
20327                    var writer = cs.writable.getWriter();
20328                    var chunks = [];
20329                    var reader = cs.readable.getReader();
20330                    var collecting = (async function () {
20331                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20332                    })();
20333
20334                    for (var i = 0; i < uploadFiles.length; i++) {
20335                      var file = uploadFiles[i];
20336                      var path = file.webkitRelativePath || file.name;
20337                      var buf  = await file.arrayBuffer();
20338                      var data = new Uint8Array(buf);
20339                      // Header block
20340                      await writer.write(buildUstarHeader(path, data.length));
20341                      // Data padded to 512-byte boundary
20342                      if (data.length > 0) {
20343                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20344                        var block  = new Uint8Array(padded);
20345                        block.set(data);
20346                        await writer.write(block);
20347                      }
20348                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20349                        if (previewPanel && targetInput === pathInput)
20350                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20351                      }
20352                    }
20353                    // End-of-archive: two 512-byte zero blocks
20354                    await writer.write(new Uint8Array(BLOCK * 2));
20355                    await writer.close();
20356                    await collecting;
20357
20358                    var blob = new Blob(chunks, { type: 'application/gzip' });
20359                    var sizeMB = (blob.size / 1048576).toFixed(1);
20360                    if (previewPanel && targetInput === pathInput)
20361                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20362
20363                    var resp = await fetch('/api/upload-tarball', {
20364                      method: 'POST',
20365                      headers: { 'Content-Type': 'application/gzip' },
20366                      body: blob
20367                    });
20368                    var d = await resp.json();
20369                    if (d && d.tmp_path) {
20370                      applyUploadResult(d.tmp_path, {
20371                        compressed_bytes: d.compressed_bytes || 0,
20372                        original_bytes: d.original_bytes || 0
20373                      });
20374                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20375                  } catch (e) {
20376                    showBannerToast('Upload failed: ' + String(e), true);
20377                    if (browseBtn) browseBtn.disabled = false;
20378                    inputEl.value = '';
20379                  }
20380                })();
20381
20382              } else {
20383                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20384                // Used only on browsers that lack CompressionStream (pre-2023).
20385                var BATCH = 200;
20386                var batches = [];
20387                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20388                var totalBatches = batches.length;
20389                if (previewPanel && targetInput === pathInput)
20390                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20391
20392                function sendBatch(idx, currentUploadId, lastTmpPath) {
20393                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20394                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
20395                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20396                  Promise.all(batches[idx].map(function (file) {
20397                    return fileToBase64(file).then(function (b64) {
20398                      return { path: file.webkitRelativePath || file.name, content: b64 };
20399                    });
20400                  })).then(function (fileList) {
20401                    var body = { files: fileList };
20402                    if (currentUploadId) body.upload_id = currentUploadId;
20403                    return fetch('/api/upload-directory', {
20404                      method: 'POST', headers: { 'Content-Type': 'application/json' },
20405                      body: JSON.stringify(body)
20406                    }).then(function (r) { return r.json(); });
20407                  }).then(function (d) {
20408                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20409                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20410                  }).catch(function (e) {
20411                    showBannerToast('Upload failed: ' + String(e), true);
20412                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20413                  });
20414                }
20415                sendBatch(0, null, '');
20416              }
20417            }
20418          };
20419          inputEl.click();
20420          return;
20421        }
20422
20423        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20424        if (browseButton) browseButton.disabled = true;
20425
20426        if (previewPanel && targetInput === pathInput) {
20427          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20428        }
20429
20430        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
20431          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20432          .then(function (data) {
20433            if (data && data.selected_path) {
20434              targetInput.value = data.selected_path;
20435              scrollInputToEnd(targetInput);
20436
20437              if (targetInput === pathInput) {
20438                updateReportTitleFromPath();
20439                autoSetOutputDir(data.selected_path);
20440                fetchProjectHistory(data.selected_path);
20441                loadPreview();
20442                suggestCoverageFile(data.selected_path);
20443              }
20444
20445              updateReview();
20446            } else if (targetInput === pathInput) {
20447              loadPreview();
20448            }
20449          })
20450          .catch(function () {
20451            window.alert("Directory picker request failed.");
20452            if (previewPanel && targetInput === pathInput) {
20453              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20454            }
20455          })
20456          .finally(function () {
20457            if (browseButton) browseButton.disabled = false;
20458          });
20459      }
20460
20461      if (themeToggle) {
20462        themeToggle.addEventListener("click", function () {
20463          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20464          applyTheme(nextTheme);
20465          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20466        });
20467      }
20468
20469      stepButtons.forEach(function (button) {
20470        button.addEventListener("click", function () {
20471          var target = Number(button.getAttribute("data-step-target"));
20472          // Block jumping forward off step 1 while the preview / upload is running
20473          // or while a multi-repository selection is unacknowledged.
20474          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20475          setStep(target);
20476        });
20477      });
20478
20479      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20480        button.addEventListener("click", function () {
20481          var target = Number(button.getAttribute("data-step-target")) || 1;
20482          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20483          setStep(target);
20484        });
20485      });
20486
20487      // True when the project path is untouched from the bundled sample default.
20488      function isDefaultSamplePath() {
20489        return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20490      }
20491
20492      var defaultPathOverlay = document.getElementById("default-path-overlay");
20493      function closeDefaultPathModal() {
20494        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20495      }
20496      function openDefaultPathModal() {
20497        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20498      }
20499
20500      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20501        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20502        // that borrow the .next-step style class but carry no data-next target).
20503        if (!button.hasAttribute("data-next")) return;
20504        button.addEventListener("click", function () {
20505          // Guard step 1 → 2: block while the scope preview / upload is still running
20506          // or while a multi-repository selection is unacknowledged.
20507          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
20508          // Guard step 1 → 2: warn when the project path is still the sample default.
20509          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20510            openDefaultPathModal();
20511            return;
20512          }
20513          updateReview();
20514          setStep(Number(button.getAttribute("data-next")));
20515        });
20516      });
20517
20518      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20519        if (!button.hasAttribute("data-prev")) return;
20520        button.addEventListener("click", function () {
20521          setStep(Number(button.getAttribute("data-prev")));
20522        });
20523      });
20524
20525      // Default-sample-path confirmation modal wiring.
20526      var defaultPathProceed = document.getElementById("default-path-proceed");
20527      if (defaultPathProceed) {
20528        defaultPathProceed.addEventListener("click", function () {
20529          closeDefaultPathModal();
20530          updateReview();
20531          setStep(2);
20532        });
20533      }
20534      var defaultPathCancel = document.getElementById("default-path-cancel");
20535      if (defaultPathCancel) {
20536        defaultPathCancel.addEventListener("click", function () {
20537          closeDefaultPathModal();
20538          if (pathInput) { pathInput.focus(); pathInput.select(); }
20539        });
20540      }
20541      if (defaultPathOverlay) {
20542        defaultPathOverlay.addEventListener("click", function (e) {
20543          if (e.target === defaultPathOverlay) closeDefaultPathModal();
20544        });
20545      }
20546      document.addEventListener("keydown", function (e) {
20547        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20548          closeDefaultPathModal();
20549        }
20550      });
20551
20552      document.addEventListener("keydown", function (e) {
20553        var tag = (document.activeElement || {}).tagName || "";
20554        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20555        if (e.altKey || e.ctrlKey || e.metaKey) return;
20556        if (e.key === "ArrowRight" && currentStep < 4) {
20557          if (currentStep === 1 && step1ForwardBlocked()) return;
20558          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20559          updateReview(); setStep(currentStep + 1);
20560        }
20561        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20562      });
20563
20564      if (useSamplePath) {
20565        useSamplePath.addEventListener("click", function () {
20566          pathInput.value = "tests/fixtures/basic";
20567          updateReportTitleFromPath();
20568          autoSetOutputDir("tests/fixtures/basic");
20569          loadPreview();
20570          suggestCoverageFile("tests/fixtures/basic");
20571        });
20572      }
20573
20574      if (useDefaultOutput) {
20575        useDefaultOutput.addEventListener("click", function () {
20576          delete outputDirInput.dataset.userEdited;
20577          autoSetOutputDir(pathInput ? pathInput.value : "");
20578          updateReview();
20579        });
20580      }
20581
20582      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20583      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20584
20585      // ── Drag-and-drop directory upload (server mode only) ─────────────────
20586      // Dropping a folder onto the path field bypasses Chrome's
20587      // "Upload X files to this site?" confirmation dialog.
20588      async function readDirRecursively(dirEntry, basePath) {
20589        var reader = dirEntry.createReader();
20590        var all = [];
20591        for (;;) {
20592          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20593          if (!batch.length) break;
20594          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20595        }
20596        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20597        var out = [];
20598        for (var i = 0; i < all.length; i++) {
20599          var sub = all[i];
20600          if (sub.isFile) {
20601            var f = await new Promise(function(res) { sub.file(res); });
20602            out.push({ file: f, path: basePath + '/' + sub.name });
20603          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20604            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20605            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20606          }
20607        }
20608        return out;
20609      }
20610
20611      function setupPathDropZone() {
20612        if (!SERVER_MODE || !pathInput) return;
20613        var CODE_EXTS = new Set([
20614          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20615          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20616          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20617          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20618          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20619          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20620        ]);
20621        pathInput.addEventListener('dragover', function(e) {
20622          e.preventDefault();
20623          pathInput.classList.add('drag-over');
20624        });
20625        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20626        pathInput.addEventListener('drop', function(e) {
20627          e.preventDefault();
20628          pathInput.classList.remove('drag-over');
20629          var items = e.dataTransfer.items;
20630          if (!items || !items.length) return;
20631          var dirEntry = null;
20632          for (var i = 0; i < items.length; i++) {
20633            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20634            if (entry && entry.isDirectory) { dirEntry = entry; break; }
20635          }
20636          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20637          var btn = browsePath;
20638          if (btn) btn.disabled = true;
20639          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20640
20641          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20642            var total = allEntries.length;
20643            var codeEntries = allEntries.filter(function(e) {
20644              var n = e.file.name;
20645              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20646              var dot = n.lastIndexOf('.');
20647              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20648            });
20649            var kept = codeEntries.length;
20650            if (kept === 0) {
20651              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20652              if (btn) btn.disabled = false; return;
20653            }
20654
20655            function finish(tmpPath, sizes) {
20656              pathInput.value = tmpPath;
20657              scrollInputToEnd(pathInput);
20658              if (sizes) {
20659                window._lastUploadSizes = sizes;
20660                var sizeText = document.getElementById('project-size-text');
20661                var sizeBtn = document.getElementById('project-size-btn');
20662                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20663                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20664                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20665                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20666              }
20667              updateReportTitleFromPath();
20668              autoSetOutputDir(tmpPath);
20669              fetchProjectHistory(tmpPath);
20670              loadPreview();
20671              suggestCoverageFile(tmpPath);
20672              updateReview();
20673              if (btn) btn.disabled = false;
20674            }
20675
20676            if (typeof CompressionStream === 'undefined') {
20677              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20678              if (btn) btn.disabled = false; return;
20679            }
20680
20681            try {
20682              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20683              var BLOCK = 512;
20684              var cs = new CompressionStream('gzip');
20685              var wtr = cs.writable.getWriter();
20686              var chunks = [];
20687              var rdr = cs.readable.getReader();
20688              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20689
20690              function buildHdr(fp, sz) {
20691                var hdr = new Uint8Array(BLOCK);
20692                var enc = new TextEncoder();
20693                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]; }
20694                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20695                var nm = fp, pfx = '';
20696                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); } }
20697                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20698                for (var i = 148; i < 156; i++) hdr[i] = 32;
20699                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);
20700                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20701                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20702                return hdr;
20703              }
20704
20705              for (var i = 0; i < codeEntries.length; i++) {
20706                var ce = codeEntries[i];
20707                var buf = await ce.file.arrayBuffer();
20708                var data = new Uint8Array(buf);
20709                await wtr.write(buildHdr(ce.path, data.length));
20710                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20711                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20712                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20713              }
20714              await wtr.write(new Uint8Array(BLOCK * 2));
20715              await wtr.close();
20716              await collecting;
20717
20718              var blob = new Blob(chunks, { type: 'application/gzip' });
20719              var sizeMB = (blob.size / 1048576).toFixed(1);
20720              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20721              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20722              var d = await resp.json();
20723              if (d && d.tmp_path) {
20724                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20725              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20726            } catch (err) {
20727              showBannerToast('Upload failed: ' + String(err), true);
20728              if (btn) btn.disabled = false;
20729            }
20730          }).catch(function(err) {
20731            showBannerToast('Could not read folder: ' + String(err), true);
20732            if (btn) btn.disabled = false;
20733          });
20734        });
20735      }
20736      setupPathDropZone();
20737      if (browseCoverage) {
20738        browseCoverage.addEventListener("click", function () {
20739          pickDirectory(coverageInput || pathInput, "coverage");
20740        });
20741      }
20742
20743      function setCovStatus(state, opts) {
20744        if (!covScanStatus) return;
20745        opts = opts || {};
20746        covScanStatus.className = "cov-scan-status cov-scan-" + state;
20747        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20748        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>';
20749        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>';
20750        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>';
20751        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>';
20752        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20753        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20754        if (state === "scanning") {
20755          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20756        } else if (state === "found") {
20757          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20758          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20759          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20760          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20761        } else if (state === "hint") {
20762          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20763          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
20764          html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML &middot; coverage.py JSON &middot; Istanbul JSON</div>';
20765        } else if (state === "none") {
20766          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20767          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
20768        }
20769        html += '</div></div>';
20770        covScanStatus.innerHTML = html;
20771        if (state === "found") {
20772          var useBtn = covScanStatus.querySelector(".cov-scan-use");
20773          if (useBtn) useBtn.addEventListener("click", function () {
20774            if (coverageInput) coverageInput.value = "";
20775            covAutoFilled = false;
20776            setCovStatus("idle");
20777          });
20778        }
20779      }
20780
20781      function suggestCoverageFile(projectPath) {
20782        if (!coverageInput || !covScanStatus) return;
20783        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20784        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20785        clearTimeout(coverageSuggestTimer);
20786        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20787        setCovStatus("scanning");
20788        coverageSuggestTimer = setTimeout(function () {
20789          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20790            .then(function (r) { return r.json(); })
20791            .then(function (d) {
20792              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20793              if (!d) { setCovStatus("none"); return; }
20794              if (d.found) {
20795                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20796                setCovStatus("found", { found: d.found, tool: d.tool });
20797              } else if (d.tool && d.hint) {
20798                setCovStatus("hint", { tool: d.tool, hint: d.hint });
20799              } else {
20800                setCovStatus("none");
20801              }
20802            })
20803            .catch(function () { setCovStatus("idle"); });
20804        }, 600);
20805      }
20806
20807      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20808
20809      if (coverageInput) coverageInput.addEventListener("input", function () {
20810        covAutoFilled = false;
20811        if (!this.value.trim()) setCovStatus("idle");
20812      });
20813
20814      // ── Language pill overflow: collapse to "+N more" chip ─────────────
20815      function collapseLanguagePills() {
20816        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20817        rows.forEach(function(row) {
20818          // Remove any previous overflow chip
20819          var prev = row.querySelector('.lang-overflow-chip');
20820          if (prev) prev.remove();
20821          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20822          pills.forEach(function(p) { p.style.display = ''; });
20823          if (!pills.length) return;
20824
20825          // Measure after restoring all pills
20826          var containerRight = row.getBoundingClientRect().right;
20827          var hidden = [];
20828          for (var i = pills.length - 1; i >= 1; i--) {
20829            var rect = pills[i].getBoundingClientRect();
20830            if (rect.right > containerRight + 2) {
20831              hidden.unshift(pills[i]);
20832              pills[i].style.display = 'none';
20833            } else {
20834              break;
20835            }
20836          }
20837
20838          if (hidden.length) {
20839            var chip = document.createElement('button');
20840            chip.type = 'button';
20841            chip.className = 'language-pill lang-overflow-chip';
20842            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20843            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20844            row.appendChild(chip);
20845          }
20846        });
20847      }
20848
20849      // Run after preview loads (preview panel populates language pills)
20850      var _origLoadPreviewCb = window.__previewLoaded;
20851      document.addEventListener('previewLoaded', collapseLanguagePills);
20852      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20853      setTimeout(collapseLanguagePills, 400);
20854
20855      // ── Project history & output dir auto-set ──────────────────────────
20856      var wsOutputRoot   = document.getElementById("ws-output-root");
20857      var wsScanCount    = document.getElementById("ws-scan-count");
20858      var wsLastScan     = document.getElementById("ws-last-scan");
20859      var historyBadge   = document.getElementById("path-history-badge");
20860      var historyTimer   = null;
20861
20862      var wsOutputLink = document.getElementById("ws-output-link");
20863      function syncStripOutputRoot() {
20864        var val = outputDirInput ? outputDirInput.value : "";
20865        var display = val || "project/sloc";
20866        if (wsOutputRoot) wsOutputRoot.textContent = display;
20867        if (wsOutputLink) wsOutputLink.dataset.folder = val;
20868      }
20869
20870      function scrollInputToEnd(input) {
20871        if (!input) return;
20872        // Defer so the DOM has the new value before we measure scroll width.
20873        requestAnimationFrame(function () {
20874          input.scrollLeft = input.scrollWidth;
20875          input.selectionStart = input.selectionEnd = input.value.length;
20876        });
20877      }
20878
20879      function autoSetOutputDir(projectPath) {
20880        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20881        if (GIT_MODE && GIT_OUTPUT_DIR) {
20882          outputDirInput.value = GIT_OUTPUT_DIR;
20883          scrollInputToEnd(outputDirInput);
20884          syncStripOutputRoot();
20885          updateReview();
20886          return;
20887        }
20888        if (!projectPath || !projectPath.trim()) return;
20889        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20890        outputDirInput.value = cleaned + "/sloc";
20891        scrollInputToEnd(outputDirInput);
20892        syncStripOutputRoot();
20893        updateReview();
20894      }
20895
20896      var wsBranch = document.getElementById("ws-branch");
20897
20898      function fetchProjectHistory(projectPath) {
20899        if (!projectPath || !projectPath.trim()) {
20900          if (wsScanCount) wsScanCount.textContent = "\u2014";
20901          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
20902          if (wsBranch)    wsBranch.textContent    = "\u2014";
20903          if (historyBadge) historyBadge.style.display = "none";
20904          return;
20905        }
20906        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20907          .then(function (r) { return r.ok ? r.json() : null; })
20908          .then(function (data) {
20909            if (!data) return;
20910            var countStr = data.scan_count > 0
20911              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20912              : "never";
20913            var tsStr = data.last_scan_timestamp
20914              ? data.last_scan_timestamp.replace(" UTC","")
20915              : "\u2014";
20916            if (wsScanCount) wsScanCount.textContent = countStr;
20917            if (wsLastScan)  wsLastScan.textContent  = tsStr;
20918            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
20919            if (data.scan_count > 0) {
20920              if (historyBadge) {
20921                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20922                historyBadge.textContent = data.scan_count + " previous scan" +
20923                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20924                  "Last: " + (data.last_scan_timestamp || "\u2014") +
20925                  " \u2014 " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?(v/1e3).toFixed(1).replace(/\.0$/,'')+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
20926                historyBadge.className = "path-history-badge found";
20927                historyBadge.style.display = "";
20928              }
20929            } else {
20930              if (historyBadge) historyBadge.style.display = "none";
20931            }
20932          })
20933          .catch(function () {});
20934      }
20935
20936      function onPathChange() {
20937        var val = pathInput ? pathInput.value : "";
20938        // Discard stale upload sizes when the user edits the path manually.
20939        window._lastUploadSizes = null;
20940        updateReportTitleFromPath();
20941        autoSetOutputDir(val);
20942        updateSidebarSummary();
20943        clearTimeout(historyTimer);
20944        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20945        if (previewTimer) clearTimeout(previewTimer);
20946        previewTimer = setTimeout(loadPreview, 280);
20947        suggestCoverageFile(val);
20948      }
20949
20950      if (pathInput) {
20951        pathInput.addEventListener("input", onPathChange);
20952      }
20953
20954      if (outputDirInput) {
20955        outputDirInput.addEventListener("input", function () {
20956          outputDirInput.dataset.userEdited = "1";
20957          syncStripOutputRoot();
20958          updateReview();
20959        });
20960      }
20961
20962      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20963        if (!node) return;
20964        node.addEventListener("input", function () {
20965          updateReview();
20966          if (previewTimer) clearTimeout(previewTimer);
20967          previewTimer = setTimeout(loadPreview, 280);
20968        });
20969      });
20970
20971      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20972        var node = document.getElementById(id);
20973        if (node) node.addEventListener("change", updateReview);
20974      });
20975
20976      if (reportTitleInput) {
20977        reportTitleInput.addEventListener("input", function () {
20978          reportTitleTouched = reportTitleInput.value.trim().length > 0;
20979          updateReportTitleFromPath();
20980          updateReview();
20981        });
20982      }
20983
20984      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20985      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20986      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20987      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20988
20989      if (coverageInput) {
20990        coverageInput.addEventListener("input", function () {
20991          if (coverageInput.value.trim()) setCovStatus("idle");
20992        });
20993      }
20994
20995      if (form && loading && submitButton) {
20996        form.addEventListener("submit", function (e) {
20997          e.preventDefault();
20998          submitButton.disabled = true;
20999          submitButton.textContent = "Scanning...";
21000          startAsyncAnalysis(new FormData(form));
21001        });
21002      }
21003
21004      function openPath(folder) {
21005        if (!folder) return;
21006        fetch('/open-path?path=' + encodeURIComponent(folder))
21007          .then(function (r) { return r.json(); })
21008          .then(function (d) {
21009            if (d && d.server_mode_disabled)
21010              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21011          })
21012          .catch(function () {});
21013      }
21014
21015      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21016        btn.addEventListener('click', function () {
21017          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21018        });
21019      });
21020
21021      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21022      if (wsOutputLink) {
21023        wsOutputLink.addEventListener('click', function () {
21024          openPath(wsOutputLink.dataset.folder || '');
21025        });
21026      }
21027
21028      loadSavedTheme();
21029      updateMixedPolicyUI();
21030      updatePythonDocstringUI();
21031      applyScanPreset();
21032      updatePresetDescriptions();
21033      applyArtifactPreset();
21034      updateReview();
21035      updateScrollProgress(); // initialise bar to 0% (step 1)
21036      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21037      onPathChange();         // seed output dir, history badge, and preview from initial path
21038      updateStepNav(1);
21039
21040      // Restore step from URL hash on initial load (e.g., back-forward cache)
21041      (function() {
21042        var hashMatch = location.hash.match(/^#step([1-4])$/);
21043        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21044      })();
21045
21046      (function randomizeWatermarks() {
21047        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21048        if (!wms.length) return;
21049        var placed = [];
21050        function tooClose(top, left) {
21051          for (var i = 0; i < placed.length; i++) {
21052            var dt = Math.abs(placed[i][0] - top);
21053            var dl = Math.abs(placed[i][1] - left);
21054            if (dt < 16 && dl < 12) return true;
21055          }
21056          return false;
21057        }
21058        function pick(leftBand) {
21059          for (var attempt = 0; attempt < 50; attempt++) {
21060            var top = Math.random() * 88 + 2;
21061            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21062            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21063          }
21064          var top = Math.random() * 88 + 2;
21065          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21066          placed.push([top, left]);
21067          return [top, left];
21068        }
21069        var half = Math.floor(wms.length / 2);
21070        wms.forEach(function (img, i) {
21071          var pos = pick(i < half);
21072          var size = Math.floor(Math.random() * 80 + 110);
21073          var rot = (Math.random() * 360).toFixed(1);
21074          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21075          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;
21076        });
21077      })();
21078
21079      (function spawnCodeParticles() {
21080        var container = document.getElementById('code-particles');
21081        if (!container) return;
21082        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'];
21083        for (var i = 0; i < 38; i++) {
21084          (function(idx) {
21085            var el = document.createElement('span');
21086            el.className = 'code-particle';
21087            el.textContent = snippets[idx % snippets.length];
21088            var left = Math.random() * 94 + 2;
21089            var top = Math.random() * 88 + 6;
21090            var dur = (Math.random() * 10 + 9).toFixed(1);
21091            var delay = (Math.random() * 18).toFixed(1);
21092            var rot = (Math.random() * 26 - 13).toFixed(1);
21093            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21094            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';
21095            container.appendChild(el);
21096          })(i);
21097        }
21098      })();
21099    })();
21100  </script>
21101  <script nonce="{{ csp_nonce }}">
21102    (function () {
21103      var raw = {{ prefill_json|safe }};
21104      if (!raw || typeof raw !== 'object' || !raw.path) return;
21105      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21106      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21107      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21108      setVal('path', raw.path || '');
21109      setVal('include_globs', raw.include_globs || '');
21110      setVal('exclude_globs', raw.exclude_globs || '');
21111      setVal('output_dir', raw.output_dir || '');
21112      setVal('report_title', raw.report_title || '');
21113      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21114      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21115      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21116      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21117      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21118      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21119      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21120      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21121      setChecked('generate_html', raw.generate_html !== false);
21122      setChecked('generate_pdf', !!raw.generate_pdf);
21123      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21124      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21125      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21126      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21127      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21128      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21129      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21130      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21131      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21132      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21133      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21134      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21135      // Trigger dynamic UI updates after pre-fill.
21136      setTimeout(function () {
21137        var pathEl = document.getElementById('path');
21138        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21139        var policyEl = document.getElementById('mixed_line_policy');
21140        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21141      }, 80);
21142    })();
21143  </script>
21144  <script nonce="{{ csp_nonce }}">
21145  (function(){
21146    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'}];
21147    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);});}
21148    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21149    function init(){
21150      var btn=document.getElementById('settings-btn');if(!btn)return;
21151      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21152      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>';
21153      document.body.appendChild(m);
21154      var g=document.getElementById('scheme-grid');
21155      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);});
21156      var cl=document.getElementById('settings-close');
21157      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);
21158      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');});
21159      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21160      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21161    }
21162    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21163  }());
21164  </script>
21165  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21166    <div class="wb-ftip-arrow"></div>
21167    <span id="wb-ftip-text"></span>
21168  </div>
21169  <script nonce="{{ csp_nonce }}">(function(){
21170    var tip=document.getElementById('wb-ftip');
21171    var txt=document.getElementById('wb-ftip-text');
21172    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21173    if(!tip||!txt)return;
21174    function pos(el){
21175      var r=el.getBoundingClientRect();
21176      tip.style.display='block';
21177      var tw=tip.offsetWidth;
21178      var lx=r.left+r.width/2-tw/2;
21179      if(lx<8)lx=8;
21180      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21181      tip.style.left=lx+'px';
21182      tip.style.top=(r.bottom+8)+'px';
21183      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';}
21184    }
21185    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21186      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21187      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21188    });
21189    window.addEventListener('blur',function(){tip.style.display='none';});
21190    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21191  })();
21192  (function(){
21193    function fixArtifactHintSpacing(){
21194      var grid=document.querySelector('.artifact-grid');
21195      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21196    }
21197    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21198  }());
21199  (function(){
21200    var dot=document.getElementById('status-dot');
21201    var pingEl=document.getElementById('server-ping-ms');
21202    var tipEl=document.getElementById('server-tip-ping');
21203    var fm=document.getElementById('footer-mode');
21204    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)';}}
21205    function doPing(){
21206      var t0=performance.now();
21207      fetch('/healthz',{cache:'no-store'})
21208        .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);})
21209        .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)';}});
21210    }
21211    doPing();
21212    setInterval(doPing,5000);
21213    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
21214  })();
21215  </script>
21216  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21217  <footer class="site-footer">
21218    local code analysis - metrics, history and reports
21219    &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>
21220    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21221    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21222    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21223    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21224  </footer>
21225</body>
21226</html>
21227"##,
21228    ext = "html"
21229)]
21230struct IndexTemplate {
21231    version: &'static str,
21232    prefill_json: String,
21233    csp_nonce: String,
21234    git_repo: String,
21235    git_ref: String,
21236    git_label_json: String,
21237    git_output_dir_json: String,
21238    server_mode: bool,
21239}
21240
21241// ── SplashTemplate ────────────────────────────────────────────────────────────
21242
21243#[derive(Template)]
21244#[template(
21245    source = r##"
21246<!doctype html>
21247<html lang="en">
21248<head>
21249  <meta charset="utf-8">
21250  <meta name="viewport" content="width=device-width, initial-scale=1">
21251  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21252  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21253  <script type="application/ld+json">
21254  {
21255    "@context": "https://schema.org",
21256    "@type": "SoftwareApplication",
21257    "name": "oxide-sloc",
21258    "applicationCategory": "DeveloperApplication",
21259    "operatingSystem": "Windows, Linux",
21260    "description": "IEEE 1045-1992 SLOC analysis workbench — CLI, web UI, MCP server, 60 languages, offline-first. Counts code, comment, and blank lines; detects unit tests; produces HTML and PDF reports.",
21261    "softwareVersion": "{{ version }}",
21262    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21263    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21264    "url": "https://github.com/oxide-sloc/oxide-sloc",
21265    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21266    "featureList": "60 language analysis, IEEE 1045-1992 SLOC counting, HTML and PDF reports, REST API, MCP server, CI/CD integration, trend reports, test metrics, git integration",
21267    "programmingLanguage": "Rust",
21268    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21269  }
21270  </script>
21271  <style nonce="{{ csp_nonce }}">
21272    :root {
21273      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21274      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21275      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21276      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21277      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21278    }
21279    body.dark-theme {
21280      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21281      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21282    }
21283    *{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;}
21284    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21285    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21286    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21287    .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;}
21288    @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));}}
21289    .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);}
21290    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21291    .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));}
21292    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21293    .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;}
21294    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21295    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21296    @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; } }
21297    .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;}
21298    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21299    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21300    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21301    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21302    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21303    .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;}
21304    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21305    .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);}
21306    .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;}
21307    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21308    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21309    .settings-modal-body{padding:14px 16px 16px;}
21310    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21311    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21312    .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;}
21313    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21314    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21315    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21316    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21317    .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;}
21318    .tz-select:focus{border-color:var(--oxide);}
21319    .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;}
21320    .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;}
21321    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21322    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21323    .hero{text-align:center;margin:0 auto 18px;}
21324    .hero-logo-wrap{display:inline-block;cursor:default;}
21325    .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;}
21326    .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;}
21327    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21328    .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;}
21329    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%);}
21330    .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;
21331      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21332      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21333      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;}
21334    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21335    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21336    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;}
21337    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21338    .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;}
21339    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21340    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21341    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21342    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21343    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21344    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21345    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21346    .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;}
21347    .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;}
21348    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21349    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21350    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21351    .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);}
21352    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21353    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21354    .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);}
21355    .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);}
21356    .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);}
21357    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21358    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21359    .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;}
21360    body.dark-theme .action-card-cta{color:var(--oxide);}
21361    .action-card.view .action-card-cta{color:var(--accent-2);}
21362    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21363    .action-card.compare .action-card-cta{color:#7c3aed;}
21364    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21365    .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);}
21366    .action-card.git-tools .action-card-cta{color:#15803d;}
21367    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21368    .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);}
21369    .action-card.trend .action-card-cta{color:#0e7490;}
21370    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21371    .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);}
21372    .action-card.automation .action-card-cta{color:#b45309;}
21373    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21374    .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);}
21375    .action-card.test-metrics .action-card-cta{color:#be185d;}
21376    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21377    .action-card:hover .action-card-cta{gap:12px;}
21378    .action-card.card-split{flex-direction:row;align-items:stretch;}
21379    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21380    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21381    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21382    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21383    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21384    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21385    .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;}
21386    .ac-badge.active{opacity:1;}
21387    .ac-badge.github{border-color:#555;color:#555;}
21388    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21389    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21390    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21391    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21392    body.dark-theme .ac-right-row{color:var(--muted);}
21393    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21394    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21395    .divider{height:1px;background:var(--line);margin:32px 0;}
21396    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21397    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21398    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21399    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21400      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21401    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21402    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21403    body.dark-theme .info-chip-val{color:var(--oxide);}
21404    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21405    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21406      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21407      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21408    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21409      border:6px solid transparent;border-top-color:var(--text);}
21410    .info-chip:hover .info-chip-tip{display:block;}
21411    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21412    .chip-slide.fading{filter:blur(5px);opacity:0;}
21413    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21414    .site-footer a{color:var(--muted);}
21415    .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;}
21416    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21417    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21418    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21419    .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;}
21420    .lan-badge.local{background:var(--oxide-2);}
21421    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21422    .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);}
21423    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21424    .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;}
21425    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21426    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21427    .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;}
21428    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21429    .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;}
21430    .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);}
21431    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21432    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21433    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21434    .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;}
21435    @media (max-height: 1100px) {
21436      .page{padding-top:10px;}
21437      .hero{margin-bottom:10px;}
21438      .hero-logo{width:54px;height:60px;}
21439      .hero-logo-shadow{width:42px;}
21440      .hero-title{font-size:28px;}
21441      .hero-subtitle{font-size:13px;}
21442      .card-sections{gap:12px;margin-bottom:6px;}
21443      .card-section-grid-2,.card-section-grid-3{gap:10px;}
21444      .action-card{padding:8px 15px 8px;}
21445      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21446      .action-card-icon svg{width:18px;height:18px;}
21447      .action-card-title{font-size:13px;}
21448      .action-card-desc{font-size:11px;margin-bottom:6px;}
21449      .action-card-cta{font-size:11px;}
21450      .ac-right-row{font-size:11px;}
21451      .divider{margin:14px 0;}
21452      .info-strip{gap:7px;margin-bottom:8px;}
21453      .info-chip{padding:7px 10px;}
21454      .info-chip-val{font-size:13px;}
21455      .info-chip-label{font-size:9px;}
21456      .site-footer{padding:8px 24px;font-size:12px;}
21457      .lan-local-hint{margin-top:8px;}
21458    }
21459    @media (max-height: 850px) {
21460      .page{padding-top:6px;}
21461      .hero{margin-bottom:6px;}
21462      .hero-logo{width:42px;height:46px;}
21463      .hero-title{font-size:22px;}
21464      .hero-subtitle{font-size:12px;}
21465      .card-sections{gap:10px;}
21466      .action-card-desc{margin-bottom:4px;}
21467      .divider{margin:8px 0;}
21468      .info-strip{margin-bottom:6px;}
21469      .lan-local-hint{margin-top:10px;}
21470    }
21471  </style>
21472</head>
21473<body>
21474  <div class="background-watermarks" aria-hidden="true">
21475    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21476    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21477    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21478    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21479    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21480    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21481    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21482  </div>
21483  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21484  <div class="top-nav">
21485    <div class="top-nav-inner">
21486      <a class="brand" href="/">
21487        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21488        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21489      </a>
21490      <div class="nav-right">
21491        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21492        <div class="nav-dropdown">
21493          <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>
21494          <div class="nav-dropdown-menu">
21495            <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>
21496          </div>
21497        </div>
21498        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21499        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21500        <div class="nav-dropdown">
21501          <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>
21502          <div class="nav-dropdown-menu">
21503            <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>
21504          </div>
21505        </div>
21506        <div class="server-status-wrap" id="server-status-wrap">
21507          <div class="nav-pill server-online-pill" id="server-status-pill">
21508            <span class="status-dot" id="status-dot"></span>
21509            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21510            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21511          </div>
21512          <div class="server-status-tip">
21513            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21514            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21515          </div>
21516        </div>
21517        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21518          <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>
21519        </button>
21520        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21521          <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>
21522          <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>
21523        </button>
21524      </div>
21525    </div>
21526  </div>
21527
21528  <div class="page">
21529    <div class="hero">
21530      <div class="hero-logo-wrap" id="hero-logo-wrap">
21531        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21532      </div>
21533      <div class="hero-logo-shadow"></div>
21534      <div class="hero-title-wrap">
21535        <div class="hero-title-aura" aria-hidden="true"></div>
21536        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21537      </div>
21538      <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>
21539    </div>
21540
21541    <div class="card-sections">
21542
21543      <div>
21544        <div class="card-section-label">Analysis</div>
21545        <div class="card-section-grid-2">
21546          <a class="action-card scan card-split" href="/scan-setup">
21547            <div class="action-card-left">
21548              <div class="action-card-icon">
21549                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21550              </div>
21551              <div class="action-card-title">Scan Project</div>
21552              <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>
21553              <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>
21554            </div>
21555            <div class="action-card-sep"></div>
21556            <div class="action-card-right">
21557              <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>
21558              <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>
21559              <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>
21560              <div class="ac-right-stat" id="acp-scan-stat"></div>
21561            </div>
21562          </a>
21563          <a class="action-card test-metrics card-split" href="/test-metrics">
21564            <div class="action-card-left">
21565              <div class="action-card-icon">
21566                <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>
21567              </div>
21568              <div class="action-card-title">Test Metrics</div>
21569              <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>
21570              <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>
21571            </div>
21572            <div class="action-card-sep"></div>
21573            <div class="action-card-right">
21574              <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>
21575              <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>
21576              <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>
21577              <div class="ac-right-stat" id="acp-test-stat"></div>
21578            </div>
21579          </a>
21580        </div>
21581      </div>
21582
21583      <div>
21584        <div class="card-section-label">Reports &amp; Insights</div>
21585        <div class="card-section-grid-3">
21586          <a class="action-card view" href="/view-reports">
21587            <div class="action-card-icon">
21588              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21589            </div>
21590            <div class="action-card-title">View Reports</div>
21591            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21592            <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>
21593          </a>
21594          <a class="action-card compare" href="/compare-scans">
21595            <div class="action-card-icon">
21596              <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>
21597            </div>
21598            <div class="action-card-title">Compare Scans</div>
21599            <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>
21600            <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>
21601          </a>
21602          <a class="action-card trend" href="/trend-reports">
21603            <div class="action-card-icon">
21604              <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>
21605            </div>
21606            <div class="action-card-title">Trend Report</div>
21607            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21608            <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>
21609          </a>
21610        </div>
21611      </div>
21612
21613      <div>
21614        <div class="card-section-label">Developer Tools</div>
21615        <div class="card-section-grid-2">
21616          <a class="action-card git-tools card-split" href="/git-browser">
21617            <div class="action-card-left">
21618              <div class="action-card-icon">
21619                <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>
21620              </div>
21621              <div class="action-card-title">Git Browser</div>
21622              <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>
21623              <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>
21624            </div>
21625            <div class="action-card-sep"></div>
21626            <div class="action-card-right">
21627              <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>
21628              <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>
21629              <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>
21630            </div>
21631          </a>
21632          <a class="action-card automation card-split" href="/integrations">
21633            <div class="action-card-left">
21634              <div class="action-card-icon">
21635                <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>
21636              </div>
21637              <div class="action-card-title">Integrations</div>
21638              <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>
21639              <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>
21640            </div>
21641            <div class="action-card-sep"></div>
21642            <div class="action-card-right">
21643              <div class="ac-badges-grid">
21644                <span class="ac-badge github"     id="acp-gh">GitHub</span>
21645                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
21646                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
21647                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21648              </div>
21649              <div class="ac-right-stat" id="acp-int-stat"></div>
21650            </div>
21651          </a>
21652        </div>
21653      </div>
21654
21655    </div>
21656
21657    {% if server_mode %}
21658    <div class="lan-card server">
21659      <div class="lan-card-header">
21660        <span class="lan-badge">LAN server</span>
21661        Accessible on your network
21662      </div>
21663      {% if let Some(ip) = lan_ip %}
21664      <div class="lan-url-row">
21665        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21666        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21667          <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>
21668          Copy URL
21669        </button>
21670      </div>
21671      <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>
21672      {% if has_api_key %}
21673      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
21674      {% endif %}
21675      {% else %}
21676      <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>
21677      {% endif %}
21678    </div>
21679    {% endif %}
21680
21681    <div class="divider"></div>
21682
21683    <div class="info-strip">
21684      <div class="info-chip">
21685        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21686        <div class="chip-slide">
21687          <div class="info-chip-val">60</div>
21688          <div class="info-chip-label">Languages</div>
21689        </div>
21690      </div>
21691      <div class="info-chip">
21692        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21693        <div class="chip-slide">
21694          <div class="info-chip-val">100%</div>
21695          <div class="info-chip-label">Self-contained</div>
21696        </div>
21697      </div>
21698      <div class="info-chip">
21699        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21700        <div class="chip-slide">
21701          <div class="info-chip-val">HTML+PDF</div>
21702          <div class="info-chip-label">Exportable reports</div>
21703        </div>
21704      </div>
21705      <div class="info-chip">
21706        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21707        <div class="chip-slide">
21708          <div class="info-chip-val">Webhook</div>
21709          <div class="info-chip-label">3 platforms</div>
21710        </div>
21711      </div>
21712      <div class="info-chip">
21713        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21714        <div class="chip-slide">
21715          <div class="info-chip-val">IEEE</div>
21716          <div class="info-chip-label">1045-1992</div>
21717        </div>
21718      </div>
21719    </div>
21720
21721    {% if lan_ip.is_none() %}
21722    <div class="lan-local-hint">
21723      <strong>Want teammates on the same network to access this?</strong><br>
21724      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
21725    </div>
21726    {% endif %}
21727  </div>
21728
21729  <footer class="site-footer">
21730    local code analysis - metrics, history and reports
21731    &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>
21732    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21733    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21734    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21735    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21736  </footer>
21737
21738  <script nonce="{{ csp_nonce }}">
21739    (function () {
21740      var storageKey = 'oxide-sloc-theme';
21741      var body = document.body;
21742      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21743      var toggle = document.getElementById('theme-toggle');
21744      if (toggle) toggle.addEventListener('click', function () {
21745        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21746        body.classList.toggle('dark-theme', next === 'dark');
21747        try { localStorage.setItem(storageKey, next); } catch(e) {}
21748      });
21749      var copyBtn = document.getElementById('lan-copy-btn');
21750      if (copyBtn) copyBtn.addEventListener('click', function() {
21751        var btn = this;
21752        var el = document.getElementById('lan-url-val');
21753        if (!el) return;
21754        var url = el.textContent.trim();
21755        if (navigator.clipboard) {
21756          navigator.clipboard.writeText(url).then(function() {
21757            var orig = btn.innerHTML;
21758            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!';
21759            setTimeout(function() { btn.innerHTML = orig; }, 1800);
21760          });
21761        }
21762      });
21763      (function randomizeWatermarks() {
21764        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21765        if (!wms.length) return;
21766        var placed = [];
21767        function tooClose(top, left) {
21768          for (var i = 0; i < placed.length; i++) {
21769            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21770            if (dt < 16 && dl < 12) return true;
21771          }
21772          return false;
21773        }
21774        function pick(leftBand) {
21775          for (var attempt = 0; attempt < 50; attempt++) {
21776            var top = Math.random() * 88 + 2;
21777            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21778            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21779          }
21780          var top = Math.random() * 88 + 2;
21781          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21782          placed.push([top, left]); return [top, left];
21783        }
21784        var half = Math.floor(wms.length / 2);
21785        wms.forEach(function (img, i) {
21786          var pos = pick(i < half);
21787          var size = Math.floor(Math.random() * 100 + 120);
21788          var rot = (Math.random() * 360).toFixed(1);
21789          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21790          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;
21791        });
21792      })();
21793
21794      (function spawnCodeParticles() {
21795        var container = document.getElementById('code-particles');
21796        if (!container) return;
21797        var snippets = [
21798          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21799          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21800          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21801          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21802          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21803        ];
21804        var count = 38;
21805        for (var i = 0; i < count; i++) {
21806          (function(idx) {
21807            var el = document.createElement('span');
21808            el.className = 'code-particle';
21809            var text = snippets[idx % snippets.length];
21810            el.textContent = text;
21811            var left = Math.random() * 94 + 2;
21812            var top = Math.random() * 88 + 6;
21813            var dur = (Math.random() * 10 + 9).toFixed(1);
21814            var delay = (Math.random() * 18).toFixed(1);
21815            var rot = (Math.random() * 26 - 13).toFixed(1);
21816            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21817            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21818              + '--rot:' + rot + 'deg;--op:' + op + ';'
21819              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21820            container.appendChild(el);
21821          })(i);
21822        }
21823      })();
21824      (function heroAnimations() {
21825        var sub = document.getElementById('hero-subtitle');
21826        if (sub) {
21827          var full = sub.textContent.trim();
21828          sub.textContent = '';
21829          sub.style.opacity = '1';
21830          var cursor = document.createElement('span');
21831          cursor.className = 'hero-cursor';
21832          sub.appendChild(cursor);
21833          var i = 0;
21834          setTimeout(function() {
21835            var iv = setInterval(function() {
21836              if (i < full.length) {
21837                sub.insertBefore(document.createTextNode(full[i]), cursor);
21838                i++;
21839              } else {
21840                clearInterval(iv);
21841                setTimeout(function() {
21842                  cursor.style.transition = 'opacity 1s ease';
21843                  cursor.style.opacity = '0';
21844                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21845                }, 2400);
21846              }
21847            }, 11);
21848          }, 374);
21849        }
21850      })();
21851      (function logoBob() {
21852        var logo = document.querySelector('.hero-logo');
21853        var shadow = document.querySelector('.hero-logo-shadow');
21854        if (!logo) return;
21855        var cycleStart = null, cycleDur = 3600;
21856        var peakY = -14, peakScale = 1.07, peakRot = 0;
21857        function newCycle() {
21858          cycleDur = 3000 + Math.random() * 1840;
21859          peakY = -(9 + Math.random() * 13.8);
21860          peakScale = 1.04 + Math.random() * 0.081;
21861          peakRot = (Math.random() * 11.5 - 5.75);
21862        }
21863        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21864        newCycle();
21865        function frame(ts) {
21866          if (cycleStart === null) cycleStart = ts;
21867          var t = (ts - cycleStart) / cycleDur;
21868          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21869          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21870          var y = peakY * phase;
21871          var sc = 1 + (peakScale - 1) * phase;
21872          var rot = peakRot * Math.sin(Math.PI * phase);
21873          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21874          if (shadow) {
21875            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21876            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21877          }
21878          requestAnimationFrame(frame);
21879        }
21880        requestAnimationFrame(frame);
21881      })();
21882      (function mouseEffects() {
21883        var heroTitle = document.getElementById('hero-title');
21884        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21885        function tick() {
21886          raf = null;
21887          if (heroTitle) {
21888            var r = heroTitle.getBoundingClientRect();
21889            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21890            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21891            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21892          }
21893        }
21894        document.addEventListener('mousemove', function(e) {
21895          mx = e.clientX; my = e.clientY;
21896          if (!raf) raf = requestAnimationFrame(tick);
21897        });
21898        document.addEventListener('mouseleave', function() {
21899          if (heroTitle) {
21900            heroTitle.style.transition = 'transform 0.5s ease';
21901            heroTitle.style.transform = '';
21902            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21903          }
21904        });
21905        document.querySelectorAll('.action-card').forEach(function(card) {
21906          card.addEventListener('mousemove', function(e) {
21907            var rect = card.getBoundingClientRect();
21908            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21909            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21910            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21911            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21912          });
21913          card.addEventListener('mouseleave', function() {
21914            card.style.transition = '';
21915            card.style.transform = '';
21916          });
21917        });
21918      })();
21919      (function chipSlideshow() {
21920        var slides = [
21921          [{v:'60',l:'Languages'},{v:'Rust \u00b7 Go \u00b7 Python',l:'and 57 more'},{v:'C \u00b7 Java \u00b7 TypeScript',l:'Swift \u00b7 Kotlin \u00b7 Zig'}],
21922          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21923          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21924          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21925          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21926        ];
21927        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21928        var indices = [0,0,0,0,0];
21929        var paused = [false,false,false,false,false];
21930        chips.forEach(function(chip, i) {
21931          chip.addEventListener('mouseenter', function() { paused[i] = true; });
21932          chip.addEventListener('mouseleave', function() { paused[i] = false; });
21933        });
21934        function advance(i) {
21935          if (paused[i]) return;
21936          var chip = chips[i];
21937          var inner = chip.querySelector('.chip-slide');
21938          if (!inner) return;
21939          inner.classList.add('fading');
21940          setTimeout(function() {
21941            indices[i] = (indices[i] + 1) % slides[i].length;
21942            var s = slides[i][indices[i]];
21943            chip.querySelector('.info-chip-val').textContent = s.v;
21944            chip.querySelector('.info-chip-label').textContent = s.l;
21945            inner.classList.remove('fading');
21946          }, 720);
21947        }
21948        setInterval(function() {
21949          chips.forEach(function(chip, i) { advance(i); });
21950        }, 6000);
21951      })();
21952      (function cardLiveData() {
21953        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21954          var el = document.getElementById('acp-scan-stat');
21955          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21956        }).catch(function(){});
21957        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21958          var el = document.getElementById('acp-test-stat');
21959          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21960        }).catch(function(){});
21961        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21962          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21963          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21964          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21965          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21966          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21967          var stat = document.getElementById('acp-int-stat');
21968          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21969        }).catch(function(){});
21970        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21971          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21972        }).catch(function(){});
21973      })();
21974    })();
21975  </script>
21976  <script nonce="{{ csp_nonce }}">
21977  (function(){
21978    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'}];
21979    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);});}
21980    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21981    function init(){
21982      var btn=document.getElementById('settings-btn');if(!btn)return;
21983      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21984      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>';
21985      document.body.appendChild(m);
21986      var g=document.getElementById('scheme-grid');
21987      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);});
21988      var cl=document.getElementById('settings-close');
21989      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);
21990      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');});
21991      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21992      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21993    }
21994    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21995  }());
21996  </script>
21997  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
21998</body>
21999</html>
22000"##,
22001    ext = "html"
22002)]
22003struct SplashTemplate {
22004    csp_nonce: String,
22005    server_mode: bool,
22006    lan_ip: Option<String>,
22007    port: u16,
22008    version: &'static str,
22009    has_api_key: bool,
22010}
22011
22012// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22013
22014#[derive(Template)]
22015#[template(
22016    source = r##"
22017<!doctype html>
22018<html lang="en">
22019<head>
22020  <meta charset="utf-8">
22021  <meta name="viewport" content="width=device-width, initial-scale=1">
22022  <title>OxideSLOC — Start a Scan</title>
22023  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22024  <style nonce="{{ csp_nonce }}">
22025    :root {
22026      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22027      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22028      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22029      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22030      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22031    }
22032    body.dark-theme {
22033      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22034      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22035    }
22036    *{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;}
22037    .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);}
22038    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22039    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22040    .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));}
22041    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22042    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22043    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22044    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22045    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22046    @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; } }
22047    .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;}
22048    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22049    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22050    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22051    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22052    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22053    .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;}
22054    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22055    .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);}
22056    .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;}
22057    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22058    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22059    .settings-modal-body{padding:14px 16px 16px;}
22060    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22061    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22062    .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;}
22063    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22064    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22065    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22066    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22067    .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;}
22068    .tz-select:focus{border-color:var(--oxide);}
22069    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22070    .page-header{text-align:center;margin-bottom:16px;}
22071    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22072    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22073    /* Cards */
22074    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22075    .option-card-wrap{position:relative;}
22076    .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;}
22077    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22078    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22079    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22080    .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;}
22081    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22082    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22083    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22084    .card-top-row{display:flex;align-items:center;gap:20px;}
22085    /* Two-column layout inside each card */
22086    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22087    .card-left{display:flex;align-items:flex-start;min-width:0;}
22088    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22089    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22090    .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);}
22091    .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);}
22092    .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);}
22093    .card-text{min-width:0;}
22094    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22095    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22096    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22097    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22098    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22099    /* Right CTA column */
22100    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22101    .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;}
22102    /* Re-scan count badge */
22103    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22104    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22105    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22106    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22107    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22108    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22109    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22110    body.dark-theme .btn-secondary{color:var(--oxide);}
22111    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22112    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22113    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22114    .file-input-wrap{position:relative;width:100%;}
22115    .file-input-wrap .btn{width:100%;}
22116    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22117    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22118    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22119    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22120    .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;}
22121    @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));}}
22122    /* Recent list (card 3 — full-width section below header) */
22123    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22124    .recent-list{display:flex;flex-direction:column;gap:8px;}
22125    .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;}
22126    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22127    .recent-item-info{flex:1;min-width:0;}
22128    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22129    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22130    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22131    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22132    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22133    .site-footer a{color:var(--muted);}
22134    @media(max-width:680px){
22135      .card-body{grid-template-columns:1fr;}
22136      .card-right{flex-direction:row;flex-wrap:wrap;}
22137      .btn{flex:1;}
22138    }
22139    .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;}
22140    .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;}
22141    .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;}
22142  </style>
22143</head>
22144<body>
22145  <div class="background-watermarks" aria-hidden="true">
22146    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22147    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22148    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22149    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22150    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22151    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22152    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22153  </div>
22154  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22155  <div class="top-nav">
22156    <div class="top-nav-inner">
22157      <a class="brand" href="/">
22158        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22159        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22160      </a>
22161      <div class="nav-right">
22162        <a class="nav-pill" href="/">Home</a>
22163        <div class="nav-dropdown">
22164          <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>
22165          <div class="nav-dropdown-menu">
22166            <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>
22167          </div>
22168        </div>
22169        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22170        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22171        <div class="nav-dropdown">
22172          <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>
22173          <div class="nav-dropdown-menu">
22174            <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>
22175          </div>
22176        </div>
22177        <div class="server-status-wrap" id="server-status-wrap">
22178          <div class="nav-pill server-online-pill" id="server-status-pill">
22179            <span class="status-dot" id="status-dot"></span>
22180            <span id="server-status-label">Server</span>
22181            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22182          </div>
22183          <div class="server-status-tip">
22184            OxideSLOC is running — accessible on your network.
22185            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22186          </div>
22187        </div>
22188        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22189          <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>
22190        </button>
22191        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22192          <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>
22193          <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>
22194        </button>
22195      </div>
22196    </div>
22197  </div>
22198
22199  <div class="page">
22200    <div class="page-header">
22201      <h1>How would you like to scan?</h1>
22202      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22203    </div>
22204
22205    <div class="option-grid">
22206
22207      <!-- Option 1: New scan -->
22208      <div class="option-card-wrap">
22209        <div class="option-card">
22210        <div class="option-icon new-scan">
22211          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22212        </div>
22213        <div class="card-body">
22214          <div class="card-left">
22215            <div class="card-text">
22216              <div class="option-title">Start a new scan</div>
22217              <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>
22218              <ul class="feature-list">
22219                <li>Live project scope preview before you run</li>
22220                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22221                <li>HTML, PDF, and JSON output — your choice</li>
22222              </ul>
22223            </div>
22224          </div>
22225          <div class="card-right">
22226            <a class="btn btn-primary" href="/scan">
22227              Configure &amp; scan
22228              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22229            </a>
22230            <p class="card-tip">Full 4-step setup · all options</p>
22231          </div>
22232        </div>
22233        </div>
22234      </div>
22235
22236      <!-- Option 2: Load from config file -->
22237      <div class="option-card-wrap">
22238        <div class="option-card">
22239        <div class="option-icon load-config">
22240          <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>
22241        </div>
22242        <div class="card-body">
22243          <div class="card-left">
22244            <div class="card-text">
22245              <div class="option-title">Load a saved config</div>
22246              <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>
22247              <ul class="feature-list">
22248                <li>All 15 settings restored from the file</li>
22249                <li>Fully editable — change path or output dir</li>
22250                <li>Works with any scan-config.json</li>
22251              </ul>
22252            </div>
22253          </div>
22254          <div class="card-right">
22255            <div class="file-input-wrap">
22256              <button class="btn btn-secondary" id="load-config-btn" type="button">
22257                <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>
22258                Choose config file
22259              </button>
22260              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22261            </div>
22262            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22263          </div>
22264        </div>
22265        </div>
22266      </div>
22267
22268      <!-- Option 3: Re-scan recent project -->
22269      <div class="option-card-wrap">
22270        <div class="option-card" id="recent-card">
22271        <div class="card-top-row">
22272          <div class="option-icon rescan">
22273            <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>
22274          </div>
22275          <div class="card-body">
22276            <div class="card-left">
22277              <div class="card-text">
22278                <div class="option-title">Re-scan a recent project</div>
22279                <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>
22280                <ul class="feature-list">
22281                  <li>All 15+ settings restored from the saved config</li>
22282                  <li>Path and output dir are editable before running</li>
22283                  <li>Only scans with a saved config appear here</li>
22284                </ul>
22285              </div>
22286            </div>
22287            <div class="card-right">
22288              <div class="rescan-count-box">
22289                <div class="rescan-count-num" id="rescan-count-num">—</div>
22290                <div class="rescan-count-label">saved configs</div>
22291              </div>
22292              <a class="btn btn-secondary" href="/view-reports">
22293                <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>
22294                View all runs
22295              </a>
22296              <p class="card-tip">Opens run history</p>
22297            </div>
22298          </div>
22299        </div>
22300        <div class="section-divider"></div>
22301        <div class="recent-list" id="recent-list">
22302          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22303        </div>
22304        </div>
22305      </div>
22306
22307    </div>
22308  </div>
22309
22310  <footer class="site-footer">
22311    local code analysis - metrics, history and reports
22312    &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>
22313    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22314    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22315    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22316    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22317  </footer>
22318
22319  <script nonce="{{ csp_nonce }}">
22320    (function () {
22321      var storageKey = 'oxide-sloc-theme';
22322      var body = document.body;
22323      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22324      var toggle = document.getElementById('theme-toggle');
22325      if (toggle) toggle.addEventListener('click', function () {
22326        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22327        body.classList.toggle('dark-theme', next === 'dark');
22328        try { localStorage.setItem(storageKey, next); } catch(e) {}
22329      });
22330
22331      (function randomizeWatermarks() {
22332        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22333        if (!wms.length) return;
22334        var placed = [];
22335        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; }
22336        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]; }
22337        var half = Math.floor(wms.length / 2);
22338        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; });
22339      })();
22340      (function spawnCodeParticles() {
22341        var container = document.getElementById('code-particles');
22342        if (!container) return;
22343        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'];
22344        var count = 38;
22345        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); }
22346      })();
22347      // Recent scans data injected from server
22348      var recentScans = {{ recent_scans_json|safe }};
22349
22350      function configToParams(cfg) {
22351        var p = new URLSearchParams();
22352        p.set('prefilled', '1');
22353        if (cfg.path) p.set('path', cfg.path);
22354        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22355        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22356        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22357        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22358        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22359        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22360        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22361        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22362        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22363        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22364        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22365        if (cfg.report_title) p.set('report_title', cfg.report_title);
22366        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22367        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22368        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22369        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22370        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22371        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22372        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22373        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22374        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22375        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22376        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22377        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22378        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22379        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22380        return p;
22381      }
22382
22383      // Build recent scan list (capped at 3 visible entries)
22384      var list = document.getElementById('recent-list');
22385      var noNote = document.getElementById('no-recent-note');
22386      var hasAny = false;
22387      var MAX_RECENT = 3;
22388      if (Array.isArray(recentScans)) {
22389        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22390        var shown = 0;
22391        validEntries.forEach(function (entry) {
22392          if (shown >= MAX_RECENT) return;
22393          shown++;
22394          hasAny = true;
22395          var item = document.createElement('div');
22396          item.className = 'recent-item';
22397          item.title = 'Restore all settings and open wizard';
22398          item.innerHTML =
22399            '<div class="recent-item-info">' +
22400              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22401              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
22402            '</div>' +
22403            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22404          item.addEventListener('click', function () {
22405            var params = configToParams(entry.config);
22406            window.location.href = '/scan?' + params.toString();
22407          });
22408          list.appendChild(item);
22409        });
22410        if (validEntries.length > MAX_RECENT) {
22411          var moreEl = document.createElement('div');
22412          moreEl.className = 'recent-more-link';
22413          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
22414          list.appendChild(moreEl);
22415        }
22416      }
22417      if (hasAny && noNote) noNote.style.display = 'none';
22418      // Update count badge
22419      var countEl = document.getElementById('rescan-count-num');
22420      if (countEl) {
22421        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22422        countEl.textContent = total > 0 ? total : '0';
22423      }
22424
22425      // Config file loader
22426      var fileInput = document.getElementById('config-file-input');
22427      var fileName = document.getElementById('config-file-name');
22428      var loadBtn = document.getElementById('load-config-btn');
22429      // Wire the visible button to open the hidden file picker.
22430      if (loadBtn && fileInput) {
22431        loadBtn.addEventListener('click', function () { fileInput.click(); });
22432      }
22433      if (fileInput) {
22434        fileInput.addEventListener('change', function () {
22435          var file = fileInput.files && fileInput.files[0];
22436          if (!file) return;
22437          if (fileName) fileName.textContent = '\u2713 ' + file.name;
22438          var reader = new FileReader();
22439          reader.onload = function (e) {
22440            try {
22441              var cfg = JSON.parse(e.target.result);
22442              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22443              var params = configToParams(cfg);
22444              window.location.href = '/scan?' + params.toString();
22445            } catch (err) {
22446              alert('Could not parse config file: ' + err.message);
22447            }
22448          };
22449          reader.readAsText(file);
22450        });
22451      }
22452
22453      function escHtml(s) {
22454        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
22455      }
22456    })();
22457  </script>
22458  <script nonce="{{ csp_nonce }}">
22459  (function(){
22460    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'}];
22461    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);});}
22462    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22463    function init(){
22464      var btn=document.getElementById('settings-btn');if(!btn)return;
22465      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22466      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>';
22467      document.body.appendChild(m);
22468      var g=document.getElementById('scheme-grid');
22469      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);});
22470      var cl=document.getElementById('settings-close');
22471      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);
22472      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');});
22473      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22474      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22475    }
22476    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22477  }());
22478  </script>
22479  <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]';
22480  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
22481  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>
22482</body>
22483</html>
22484"##,
22485    ext = "html"
22486)]
22487struct ScanSetupTemplate {
22488    version: &'static str,
22489    recent_scans_json: String,
22490    csp_nonce: String,
22491}
22492
22493#[derive(Template)]
22494#[template(
22495    source = r##"
22496<!doctype html>
22497<html lang="en">
22498<head>
22499  <meta charset="utf-8">
22500  <meta name="viewport" content="width=device-width, initial-scale=1">
22501  <title>OxideSLOC | {{ report_title }} | Report</title>
22502  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22503  <style nonce="{{ csp_nonce }}">
22504    :root {
22505      --radius: 18px;
22506      --bg: #f5efe8;
22507      --surface: rgba(255,255,255,0.82);
22508      --surface-2: #fbf7f2;
22509      --surface-3: #efe6dc;
22510      --line: #e6d0bf;
22511      --line-strong: #dcb89f;
22512      --text: #43342d;
22513      --muted: #7b675b;
22514      --muted-2: #a08777;
22515      --nav: #b85d33;
22516      --nav-2: #7a371b;
22517      --accent: #6f9bff;
22518      --accent-2: #4a78ee;
22519      --oxide: #d37a4c;
22520      --oxide-2: #b35428;
22521      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22522      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22523      --success-bg: #e8f5ed;
22524      --success-text: #1a8f47;
22525      --info-bg: #eef3ff;
22526      --info-text: #4467d8;
22527    }
22528
22529    body.dark-theme {
22530      --bg: #1b1511;
22531      --surface: #261c17;
22532      --surface-2: #2d221d;
22533      --surface-3: #372922;
22534      --line: #524238;
22535      --line-strong: #6c5649;
22536      --text: #f5ece6;
22537      --muted: #c7b7aa;
22538      --muted-2: #aa9485;
22539      --nav: #b85d33;
22540      --nav-2: #7a371b;
22541      --accent: #6f9bff;
22542      --accent-2: #4a78ee;
22543      --oxide: #d37a4c;
22544      --oxide-2: #b35428;
22545      --shadow: 0 18px 42px rgba(0,0,0,0.28);
22546      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22547      --success-bg: #163927;
22548      --success-text: #8fe2a8;
22549      --info-bg: #1c2847;
22550      --info-text: #a9c1ff;
22551    }
22552
22553    * { box-sizing: border-box; }
22554    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); }
22555    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22556    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22557    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22558    .top-nav, .page { position: relative; z-index: 2; }
22559    .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); }
22560    .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; }
22561    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22562    .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)); }
22563    .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; }
22564    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22565    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22566    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22567    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22568    .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; }
22569    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22570    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22571    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22572    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22573    @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; } }
22574    .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; }
22575    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22576    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22577    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22578    .theme-toggle .icon-sun { display:none; }
22579    body.dark-theme .theme-toggle .icon-sun { display:block; }
22580    body.dark-theme .theme-toggle .icon-moon { display:none; }
22581    .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;}
22582    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22583    .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);}
22584    .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;}
22585    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22586    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22587    .settings-modal-body{padding:14px 16px 16px;}
22588    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22589    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22590    .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;}
22591    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22592    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22593    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22594    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22595    .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;}
22596    .tz-select:focus{border-color:var(--oxide);}
22597    .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; }
22598    .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;}
22599    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22600    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22601    .hero, .panel { padding: 22px; }
22602    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22603    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22604    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22605    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22606    .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; }
22607    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22608    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22609    .compare-banner-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; border-top: 1px solid rgba(100,130,220,0.15); padding-top: 10px; }
22610    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22611    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22612    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22613    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22614    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22615    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22616    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
22617    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22618    .delta-card-val { font-size:16px; font-weight:800; }
22619    .delta-card-val.pos { color:#1e7e34; }
22620    .delta-card-val.neg { color:var(--neg); }
22621    .delta-card-val.mod { color:#b35428; }
22622    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22623    .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; }
22624    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22625    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22626    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22627    .compare-ts { font-size:13px; color:var(--muted); }
22628    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22629    .compare-arrow { color: var(--muted); }
22630    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22631    .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; }
22632    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22633    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22634    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22635    .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; }
22636    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22637    .run-mgmt-card .action-buttons { justify-content:center; }
22638    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22639    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22640    .button, .copy-button {
22641      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;
22642    }
22643    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22644    @keyframes spin { to { transform: rotate(360deg); } }
22645    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22646    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22647    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22648    .path-item strong { display: block; margin-bottom: 6px; }
22649    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22650    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22651    .path-subitem { flex: 1; }
22652    .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); }
22653    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); }
22654    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22655    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22656    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22657    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22658    th { color: var(--muted); font-weight: 700; }
22659    tr:last-child td { border-bottom: none; }
22660    #subm-tbl col:nth-child(1){width:15%;}
22661    #subm-tbl col:nth-child(2){width:31%;}
22662    #subm-tbl col:nth-child(3){width:9%;}
22663    #subm-tbl col:nth-child(4){width:9%;}
22664    #subm-tbl col:nth-child(5){width:9%;}
22665    #subm-tbl col:nth-child(6){width:9%;}
22666    #subm-tbl col:nth-child(7){width:9%;}
22667    #subm-tbl col:nth-child(8){width:9%;}
22668    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22669    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22670    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22671    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22672    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22673    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22674    .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; }
22675    .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; }
22676    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22677    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22678    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22679    .muted { color: var(--muted); }
22680    /* Run-ID chip row (mirrors HTML report) */
22681    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22682    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22683    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22684    .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; }
22685    .run-id-chip[data-copy] { cursor:pointer; }
22686    a.run-id-chip { text-decoration:none; cursor:pointer; }
22687    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22688    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22689    .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; }
22690    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22691    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22692    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22693    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22694    a.commit-link-value { color:inherit; text-decoration:none; }
22695    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22696    .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
22697    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22698    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22699    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22700    .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; }
22701    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22702    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22703    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22704    /* Meta chips row */
22705    .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%; }
22706    .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; }
22707    .meta-chip:last-child { border-right:none; }
22708    .meta-chip b { color:var(--text); font-weight:700; }
22709    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22710    .site-footer a{color:var(--muted);}
22711    .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; }
22712    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22713    .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; }
22714    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22715    /* Stat chips (matches HTML report) */
22716    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22717    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22718    /* Hero stat strip: uniform grid where every card is the same width and the
22719       columns line up across both rows. JS sets the column count to ceil(n/2) so
22720       the cards always occupy exactly two rows; when the count is odd the last
22721       card spans two columns to fill the trailing cell with no empty gap. */
22722    .summary-strip-hero { align-items:stretch; }
22723    .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); overflow:visible; }
22724    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22725    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22726    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22727    .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; }
22728    .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
22729    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22730    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22731    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22732    .cocomo-box-head { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
22733    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22734    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22735    .cocomo-mode-pill { display:inline-flex; align-items:center; padding:3px 10px; border-radius:999px; background:var(--surface-3); border:1px solid var(--line-strong); font-size:11px; font-weight:700; color:var(--muted); }
22736    .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
22737    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22738    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22739    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22740    /* Submodule panel */
22741    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22742    /* Metrics tables stack */
22743    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22744    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22745    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22746    .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)); }
22747    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22748    /* Metrics table */
22749    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22750    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22751    .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; }
22752    .metrics-table thead th:not(:first-child) { text-align: right; }
22753    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22754    .metrics-table tbody tr:last-child td { border-bottom: none; }
22755    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22756    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22757    .metrics-table tbody tr:hover td { background: var(--surface-2); }
22758    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22759    .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; }
22760    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22761    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22762    .mt-val-pos { color: var(--pos); font-weight: 700; }
22763    .mt-val-neg { color: var(--neg); font-weight: 700; }
22764    .mt-val-zero { color: var(--muted); }
22765    .mt-val-mod { color: var(--oxide-2); }
22766    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22767    @media (max-width: 1180px) {
22768      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22769      .nav-project-slot, .nav-status { justify-content:flex-start; }
22770      .hero-top { flex-direction: column; }
22771      .run-mgmt-strip { flex-direction: column; }
22772    }
22773    .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;}
22774    @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));}}
22775    .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;}
22776    /* ── Result-page chart controls ─────────────────────────────────────────── */
22777    .r-chart-section{margin-bottom:24px;}
22778    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22779    .section-pair > .panel{flex-shrink:0;}
22780    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22781    .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;}
22782    .r-chart-select:focus{border-color:var(--accent);}
22783    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22784    .r-chart-container svg{display:block;width:100%;height:auto;}
22785    .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;}
22786    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22787    .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;}
22788    .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);}
22789    .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;}
22790    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22791    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22792    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22793    .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;}
22794    .r-chart-modal-close:hover{opacity:.7;}
22795    body.dark-theme .r-chart-modal{background:var(--surface);}
22796    .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;}
22797    .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);}
22798    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22799    .lang-bar-row:hover{transform:translateY(-2px);}
22800    .lang-bar-row .rchit:hover{filter:none;transform:none;}
22801    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22802    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22803    .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;}
22804    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22805    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22806    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22807    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22808    #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;}
22809    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22810    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22811    .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;}
22812    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22813    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22814    .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;}
22815    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22816    .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;}
22817    .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;}
22818    body.has-report-banner .top-nav{top:27px;}
22819    body.has-report-banner{padding-bottom:27px;}
22820  </style>
22821</head>
22822<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22823  <div class="background-watermarks" aria-hidden="true">
22824    <img src="/images/logo/logo-text.png" alt="" />
22825    <img src="/images/logo/logo-text.png" alt="" />
22826    <img src="/images/logo/logo-text.png" alt="" />
22827    <img src="/images/logo/logo-text.png" alt="" />
22828    <img src="/images/logo/logo-text.png" alt="" />
22829    <img src="/images/logo/logo-text.png" alt="" />
22830    <img src="/images/logo/logo-text.png" alt="" />
22831    <img src="/images/logo/logo-text.png" alt="" />
22832    <img src="/images/logo/logo-text.png" alt="" />
22833    <img src="/images/logo/logo-text.png" alt="" />
22834    <img src="/images/logo/logo-text.png" alt="" />
22835    <img src="/images/logo/logo-text.png" alt="" />
22836    <img src="/images/logo/logo-text.png" alt="" />
22837    <img src="/images/logo/logo-text.png" alt="" />
22838  </div>
22839  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22840  {% if let Some(banner) = report_header_footer %}
22841  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22842  {% endif %}
22843  <div class="top-nav">
22844    <div class="top-nav-inner">
22845      <a class="brand" href="/">
22846        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22847        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22848      </a>
22849      <div class="nav-project-slot">
22850        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22851      </div>
22852      <div class="nav-status">
22853        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22854        <div class="nav-dropdown">
22855          <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>
22856          <div class="nav-dropdown-menu">
22857            <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>
22858          </div>
22859        </div>
22860        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22861        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22862        <div class="nav-dropdown">
22863          <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>
22864          <div class="nav-dropdown-menu">
22865            <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>
22866          </div>
22867        </div>
22868        <div class="server-status-wrap" id="server-status-wrap">
22869          <div class="nav-pill server-online-pill" id="server-status-pill">
22870            <span class="status-dot" id="status-dot"></span>
22871            <span id="server-status-label">Server</span>
22872            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22873          </div>
22874          <div class="server-status-tip">
22875            OxideSLOC is running — accessible on your network.
22876            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22877          </div>
22878        </div>
22879        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22880          <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>
22881        </button>
22882        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22883          <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>
22884          <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>
22885        </button>
22886      </div>
22887    </div>
22888  </div>
22889
22890  <div class="page">
22891    <section class="hero">
22892      <div class="hero-top">
22893        <div>
22894          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22895            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22896            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22897            <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>
22898          </div>
22899        </div>
22900        <div class="hero-quick-actions">
22901          {% if server_mode %}
22902          <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>
22903          {% else %}
22904          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22905          {% endif %}
22906          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22907          {% if !server_mode %}
22908          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22909          {% endif %}
22910          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22911          <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>
22912        </div>
22913      </div>
22914
22915      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22916      <div class="run-id-row">
22917        <span class="run-id-chip" data-copy="{{ run_id }}">
22918          <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>
22919          <span class="run-id-chip-value">{{ run_id }}</span>
22920          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22921        </span>
22922        {% match git_commit_long %}
22923          {% when Some with (long_sha) %}
22924          {% match git_commit_url %}
22925            {% when Some with (commit_url) %}
22926            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22927              <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>
22928              <span class="run-id-chip-value">{{ long_sha }}</span>
22929              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22930            </a>
22931            {% when None %}
22932            <span class="run-id-chip" data-copy="{{ long_sha }}">
22933              <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>
22934              <span class="run-id-chip-value">{{ long_sha }}</span>
22935              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22936            </span>
22937          {% endmatch %}
22938          {% when None %}
22939          <span class="run-id-chip muted-chip">
22940            <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>
22941            <span class="run-id-chip-value">Not detected</span>
22942            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22943          </span>
22944        {% endmatch %}
22945        {% match git_branch %}
22946          {% when Some with (branch) %}
22947          {% match git_branch_url %}
22948            {% when Some with (branch_url) %}
22949            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22950              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
22951              <span class="run-id-chip-value">{{ branch }}</span>
22952              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22953            </a>
22954            {% when None %}
22955            <span class="run-id-chip">
22956              <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>
22957              <span class="run-id-chip-value">{{ branch }}</span>
22958              <span class="chip-tooltip">Git branch active at scan time</span>
22959            </span>
22960          {% endmatch %}
22961          {% when None %}
22962          <span class="run-id-chip muted-chip">
22963            <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>
22964            <span class="run-id-chip-value">Not detected</span>
22965            <span class="chip-tooltip">No Git branch was found for this scan</span>
22966          </span>
22967        {% endmatch %}
22968        {% match git_author %}
22969          {% when Some with (author) %}
22970          <span class="run-id-chip" data-author="{{ author }}">
22971            <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>
22972            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22973            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22974          </span>
22975          {% when None %}
22976          <span class="run-id-chip muted-chip">
22977            <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>
22978            <span class="run-id-chip-value">Not detected</span>
22979            <span class="chip-tooltip">No commit author was found for this scan</span>
22980          </span>
22981        {% endmatch %}
22982      </div>
22983
22984      <!-- Scan metadata row -->
22985      <div class="meta">
22986        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22987        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22988        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22989        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22990        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22991      </div>
22992
22993      <!-- All summary stat chips in one unified strip (8 columns) -->
22994      <div class="summary-strip summary-strip-hero">
22995        <div class="stat-chip" data-raw="{{ physical_lines }}">
22996          <div class="stat-chip-label">Physical lines</div>
22997          <div class="stat-chip-val">{{ physical_lines }}</div>
22998          <div class="stat-chip-exact"></div>
22999          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23000        </div>
23001        <div class="stat-chip" data-raw="{{ code_lines }}">
23002          <div class="stat-chip-label">Code</div>
23003          <div class="stat-chip-val">{{ code_lines }}</div>
23004          <div class="stat-chip-exact"></div>
23005          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23006        </div>
23007        <div class="stat-chip" data-raw="{{ comment_lines }}">
23008          <div class="stat-chip-label">Comments</div>
23009          <div class="stat-chip-val">{{ comment_lines }}</div>
23010          <div class="stat-chip-exact"></div>
23011          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23012        </div>
23013        <div class="stat-chip" data-raw="{{ blank_lines }}">
23014          <div class="stat-chip-label">Blank</div>
23015          <div class="stat-chip-val">{{ blank_lines }}</div>
23016          <div class="stat-chip-exact"></div>
23017          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23018        </div>
23019        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23020          <div class="stat-chip-label">Mixed separate</div>
23021          <div class="stat-chip-val">{{ mixed_lines }}</div>
23022          <div class="stat-chip-exact"></div>
23023          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23024        </div>
23025        <div class="stat-chip" data-raw="{{ functions }}">
23026          <div class="stat-chip-label">Functions</div>
23027          <div class="stat-chip-val">{{ functions }}</div>
23028          <div class="stat-chip-exact"></div>
23029          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23030        </div>
23031        <div class="stat-chip" data-raw="{{ classes }}">
23032          <div class="stat-chip-label">Classes / Types</div>
23033          <div class="stat-chip-val">{{ classes }}</div>
23034          <div class="stat-chip-exact"></div>
23035          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23036        </div>
23037        <div class="stat-chip" data-raw="{{ variables }}">
23038          <div class="stat-chip-label">Variables</div>
23039          <div class="stat-chip-val">{{ variables }}</div>
23040          <div class="stat-chip-exact"></div>
23041          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23042        </div>
23043        <div class="stat-chip" data-raw="{{ imports }}">
23044          <div class="stat-chip-label">Imports</div>
23045          <div class="stat-chip-val">{{ imports }}</div>
23046          <div class="stat-chip-exact"></div>
23047          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23048        </div>
23049        <div class="stat-chip" data-raw="{{ test_count }}">
23050          <div class="stat-chip-label">Tests</div>
23051          <div class="stat-chip-val">{{ test_count }}</div>
23052          <div class="stat-chip-exact"></div>
23053          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23054        </div>
23055        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23056          <div class="stat-chip-label">Code density</div>
23057          <div class="stat-chip-val stat-chip-density-val">—</div>
23058          <div class="stat-chip-exact"></div>
23059          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23060        </div>
23061        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23062          <div class="stat-chip-label">Files analyzed</div>
23063          <div class="stat-chip-val">{{ files_analyzed }}</div>
23064          <div class="stat-chip-exact"></div>
23065          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23066        </div>
23067        {% if cyclomatic_complexity > 0 %}
23068        <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}style="border-color:var(--oxide-2);"{% endif %}>
23069          <div class="stat-chip-label">Complexity score</div>
23070          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23071          <div class="stat-chip-exact"></div>
23072          <div class="stat-chip-tip">Sum of branch decision keywords (if, for, while, ||, &amp;&amp;, …) across all code lines — a lexical approximation of McCabe cyclomatic complexity.{% if complexity_alert > 0 %} Alert threshold: {{ complexity_alert }}.{% endif %}</div>
23073        </div>
23074        {% endif %}
23075        {% if let Some(ls) = lsloc %}
23076        <div class="stat-chip" data-raw="{{ ls }}">
23077          <div class="stat-chip-label">Logical SLOC</div>
23078          <div class="stat-chip-val">{{ ls }}</div>
23079          <div class="stat-chip-exact"></div>
23080          <div class="stat-chip-tip">Count of executable statements (semicolons for C/Java/Go/Rust; non-continuation lines for Python/Ruby/Shell). Normalises across formatting styles.</div>
23081        </div>
23082        {% endif %}
23083        {% if uloc > 0 %}
23084        <div class="stat-chip" data-raw="{{ uloc }}">
23085          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23086          <div class="stat-chip-val">{{ uloc }}</div>
23087          <div class="stat-chip-exact"></div>
23088          <div class="stat-chip-tip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div>
23089        </div>
23090        {% endif %}
23091        {% if uloc > 0 && dryness_pct_str != "" %}
23092        <div class="stat-chip">
23093          <div class="stat-chip-label">DRYness</div>
23094          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23095          <div class="stat-chip-exact"></div>
23096          <div class="stat-chip-tip">ULOC &divide; Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div>
23097        </div>
23098        {% endif %}
23099        {% if duplicate_group_count > 0 %}
23100        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23101          <div class="stat-chip-label">Duplicate groups</div>
23102          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23103          <div class="stat-chip-exact"></div>
23104          <div class="stat-chip-tip">Groups of files with identical content detected. These may inflate SLOC counts. Enable "Exclude duplicates" in scan settings to remove them from totals.</div>
23105        </div>
23106        {% endif %}
23107        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23108             odd, so the strip always forms exactly two full rows with every column
23109             aligned and every card the same width (no oversized card, no gap). -->
23110        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23111          <div class="stat-chip-label">Assertions</div>
23112          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23113          <div class="stat-chip-exact"></div>
23114          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23115        </div>
23116      </div>
23117
23118      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23119      <div class="compare-banner">
23120        <div class="compare-banner-body">
23121          <div class="compare-banner-top">
23122          <div class="compare-banner-meta">
23123            <span class="compare-label">Previous scan</span>
23124            <span class="compare-ts">{{ prev_ts }}</span>
23125            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23126            {% if let Some(prev_code) = prev_run_code_lines %}
23127            <div class="compare-banner-stats" style="margin-top:4px;">
23128              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23129              <span class="compare-arrow">→</span>
23130              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23131              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23132              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23133            </div>
23134            {% endif %}
23135          </div>
23136          {% if delta_lines_added.is_some() %}
23137          <div class="delta-cards-inline">
23138            <div class="delta-card-inline">
23139              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23140              <div class="delta-card-lbl">lines added</div>
23141              <div class="delta-card-tip">Code lines added since the previous scan</div>
23142            </div>
23143            <div class="delta-card-inline">
23144              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23145              <div class="delta-card-lbl">lines removed</div>
23146              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23147            </div>
23148            <div class="delta-card-inline">
23149              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23150              <div class="delta-card-lbl">unmodified lines</div>
23151              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23152            </div>
23153            <div class="delta-card-inline">
23154              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23155              <div class="delta-card-lbl">files modified</div>
23156              <div class="delta-card-tip">Files with at least one line changed</div>
23157            </div>
23158            <div class="delta-card-inline">
23159              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23160              <div class="delta-card-lbl">files added</div>
23161              <div class="delta-card-tip">New files added since the previous scan</div>
23162            </div>
23163            <div class="delta-card-inline">
23164              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23165              <div class="delta-card-lbl">files removed</div>
23166              <div class="delta-card-tip">Files deleted since the previous scan</div>
23167            </div>
23168            <div class="delta-card-inline">
23169              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23170              <div class="delta-card-lbl">files unchanged</div>
23171              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23172            </div>
23173          </div>
23174          {% else %}
23175          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23176            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23177          </p>
23178          {% endif %}
23179          </div>
23180          <div class="compare-banner-actions">
23181            <div class="compare-banner-actions-left">
23182              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23183              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23184            </div>
23185            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23186          </div>
23187        </div>
23188      </div>
23189      {% endif %}{% endif %}
23190
23191      <div class="action-grid">
23192        <div class="action-card">
23193          <h3>HTML report</h3>
23194          <div class="action-buttons">
23195            {% match html_url %}
23196              {% when Some with (url) %}
23197                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23198              {% when None %}{% endmatch %}
23199            {% match html_download_url %}
23200              {% when Some with (url) %}
23201                <a class="button secondary" href="{{ url }}">Download HTML</a>
23202              {% when None %}{% endmatch %}
23203            {% match html_path %}
23204              {% when Some with (_path) %}{% when None %}{% endmatch %}
23205            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23206          </div>
23207        </div>
23208        <div class="action-card">
23209          <h3>PDF report</h3>
23210          <div class="action-buttons">
23211            {% match pdf_url %}
23212              {% when Some with (url) %}
23213                {% if pdf_generating %}
23214                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23215                    <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>
23216                    Generating PDF…
23217                  </button>
23218                {% else %}
23219                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23220                {% endif %}
23221              {% when None %}
23222                {% match html_url %}
23223                  {% when Some with (_hurl) %}
23224                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23225                    <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>
23226                  {% when None %}
23227                    <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;">
23228                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23229                    </p>
23230                {% endmatch %}
23231            {% endmatch %}
23232            {% match pdf_download_url %}
23233              {% when Some with (url) %}
23234                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23235              {% when None %}{% endmatch %}
23236            {% match pdf_url %}
23237              {% when Some with (_) %}
23238                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23239              {% when None %}{% endmatch %}
23240          </div>
23241        </div>
23242        <div class="action-card">
23243          <h3>JSON result</h3>
23244          <div class="action-buttons">
23245            {% match json_url %}
23246              {% when Some with (url) %}
23247                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23248              {% when None %}{% endmatch %}
23249            {% match json_download_url %}
23250              {% when Some with (url) %}
23251                <a class="button secondary" href="{{ url }}">Download JSON</a>
23252              {% when None %}{% endmatch %}
23253            {% match json_path %}
23254              {% when Some with (_path) %}
23255                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23256              {% when None %}
23257                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23258              {% endmatch %}
23259          </div>
23260        </div>
23261        <div class="action-card">
23262          <h3>Scan config</h3>
23263          <div class="action-buttons">
23264            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23265            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23266            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23267          </div>
23268        </div>
23269        {% if confluence_configured %}
23270        <div class="action-card" id="confluenceCard">
23271          <h3>Confluence</h3>
23272          <div class="action-buttons">
23273            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23274            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23275          </div>
23276          <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>
23277        </div>
23278        {% endif %}
23279      </div>
23280      {% if confluence_configured %}
23281      <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;">
23282        <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);">
23283          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23284          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23285          <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;">
23286          <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>
23287          <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;">
23288          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23289          <div style="display:flex;gap:10px;justify-content:flex-end;">
23290            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23291            <button class="button" id="confSubmitBtn" type="button">Post</button>
23292          </div>
23293        </div>
23294      </div>
23295      {% endif %}
23296      <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;">
23297        <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);">
23298          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23299          <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>
23300          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23301          <div style="display:flex;gap:18px;justify-content:flex-end;">
23302            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23303            <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>
23304          </div>
23305        </div>
23306      </div>
23307      {% if !submodule_rows.is_empty() %}
23308      <div class="submodule-panel">
23309        <div class="toolbar-row">
23310          <div>
23311            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23312            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23313          </div>
23314          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23315        </div>
23316        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23317        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23318          <colgroup><col style="width:24%"><col style="width:22%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
23319          <thead>
23320            <tr>
23321              <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>
23322              <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>
23323              <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>
23324              <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>
23325              <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>
23326              <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>
23327              <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>
23328              <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>
23329            </tr>
23330          </thead>
23331          <tbody>
23332            {% for row in submodule_rows %}
23333            <tr>
23334              <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>
23335              <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>
23336              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23337              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23338              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23339              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23340              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23341              <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>
23342            </tr>
23343            {% endfor %}
23344          </tbody>
23345        </table>
23346        </div>
23347      </div>
23348      {% endif %}
23349
23350      <div class="metrics-tables-stack">
23351
23352        <div class="metrics-table-wrap">
23353          <div class="metrics-table-title">Files</div>
23354          <table class="metrics-table">
23355            <thead>
23356              <tr>
23357                <th>Metric</th>
23358                <th>This Run</th>
23359                <th>Previous</th>
23360                <th>Change</th>
23361              </tr>
23362            </thead>
23363            <tbody>
23364              <tr>
23365                <td>Files analyzed</td>
23366                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23367                <td>{{ prev_fa_str|commas }}</td>
23368                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23369              </tr>
23370              <tr>
23371                <td>Files skipped</td>
23372                <td>{{ files_skipped|commas }}</td>
23373                <td>{{ prev_fs_str|commas }}</td>
23374                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23375              </tr>
23376              <tr>
23377                <td>Files modified</td>
23378                <td class="mt-val-na">—</td>
23379                <td class="mt-val-na">—</td>
23380                <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v|commas }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23381              </tr>
23382              <tr>
23383                <td>Files unchanged</td>
23384                <td class="mt-val-na">—</td>
23385                <td class="mt-val-na">—</td>
23386                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23387              </tr>
23388            </tbody>
23389          </table>
23390        </div>
23391
23392        <div class="metrics-table-wrap">
23393          <div class="metrics-table-title">Line Counts</div>
23394          <table class="metrics-table">
23395            <thead>
23396              <tr>
23397                <th>Metric</th>
23398                <th>This Run</th>
23399                <th>Previous</th>
23400                <th>Change</th>
23401              </tr>
23402            </thead>
23403            <tbody>
23404              <tr>
23405                <td>Physical lines</td>
23406                <td class="mt-val-large">{{ physical_lines|commas }}</td>
23407                <td>{{ prev_pl_str|commas }}</td>
23408                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23409              </tr>
23410              <tr>
23411                <td>Code lines</td>
23412                <td class="mt-val-large">{{ code_lines|commas }}</td>
23413                <td>{{ prev_cl_str|commas }}</td>
23414                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23415              </tr>
23416              <tr>
23417                <td>Comment lines</td>
23418                <td>{{ comment_lines|commas }}</td>
23419                <td>{{ prev_cml_str|commas }}</td>
23420                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23421              </tr>
23422              <tr>
23423                <td>Blank lines</td>
23424                <td>{{ blank_lines|commas }}</td>
23425                <td>{{ prev_bl_str|commas }}</td>
23426                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23427              </tr>
23428              <tr>
23429                <td>Mixed (separate)</td>
23430                <td>{{ mixed_lines|commas }}</td>
23431                <td class="mt-val-na">—</td>
23432                <td class="mt-val-na">—</td>
23433              </tr>
23434            </tbody>
23435          </table>
23436        </div>
23437
23438        <div class="metrics-tables-lower">
23439          <div class="metrics-table-wrap">
23440            <div class="metrics-table-title">Code Structure</div>
23441            <table class="metrics-table">
23442              <thead>
23443                <tr>
23444                  <th>Metric</th>
23445                  <th>This Run</th>
23446                </tr>
23447              </thead>
23448              <tbody>
23449                <tr>
23450                  <td>Functions</td>
23451                  <td>{{ functions|commas }}</td>
23452                </tr>
23453                <tr>
23454                  <td>Classes / Types</td>
23455                  <td>{{ classes|commas }}</td>
23456                </tr>
23457                <tr>
23458                  <td>Variables</td>
23459                  <td>{{ variables|commas }}</td>
23460                </tr>
23461                <tr>
23462                  <td>Imports</td>
23463                  <td>{{ imports|commas }}</td>
23464                </tr>
23465              </tbody>
23466            </table>
23467          </div>
23468
23469          <div class="metrics-table-wrap">
23470            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23471            <table class="metrics-table">
23472              <thead>
23473                <tr>
23474                  <th>Metric</th>
23475                  <th>Change</th>
23476                </tr>
23477              </thead>
23478              <tbody>
23479                <tr>
23480                  <td>Lines added</td>
23481                  <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23482                </tr>
23483                <tr>
23484                  <td>Lines removed</td>
23485                  <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">&minus;{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23486                </tr>
23487                <tr>
23488                  <td>Lines modified (net)</td>
23489                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23490                </tr>
23491                <tr>
23492                  <td>Lines unmodified</td>
23493                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23494                </tr>
23495              </tbody>
23496            </table>
23497          </div>
23498        </div>
23499
23500      </div>
23501
23502      <div class="path-list">
23503        <div class="path-item">
23504          <div class="path-item-label">Project path</div>
23505          {% if project_path.is_empty() %}<code style="color:var(--muted)" title="The scanned project path was not recorded in this run's metadata.">Not recorded for this scan</code>{% else %}<code>{{ project_path }}</code>{% endif %}
23506        </div>
23507        <div class="path-item">
23508          <div class="path-item-label">Git branch</div>
23509          {% if let Some(branch) = git_branch %}
23510          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23511          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23512          {% else %}
23513          <code style="color:var(--muted)">—</code>
23514          {% endif %}
23515        </div>
23516        <div class="path-item">
23517          <div class="path-item-label">Output folder</div>
23518          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23519        </div>
23520        <div class="path-item">
23521          <div class="path-item-label">Run ID</div>
23522          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23523            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23524            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23525          </div>
23526        </div>
23527      </div>
23528    </section>
23529
23530    {% if has_cocomo %}
23531    <div class="cocomo-box" style="margin-top:24px;">
23532      <div class="cocomo-box-head">
23533        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
23534        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23535          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23536          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23537        </span>
23538      </div>
23539      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23540        <div class="stat-chip">
23541          <div class="stat-chip-label">Person-months</div>
23542          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23543          <div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 &times; KSLOC^1.05 ({{ cocomo_mode_label }} mode).</div>
23544        </div>
23545        <div class="stat-chip">
23546          <div class="stat-chip-label">Schedule (months)</div>
23547          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23548          <div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 &times; effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
23549        </div>
23550        <div class="stat-chip">
23551          <div class="stat-chip-label">Avg. Team Size</div>
23552          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23553          <div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort &divide; schedule. Actual headcount may peak higher during intensive phases of the project.</div>
23554        </div>
23555        <div class="stat-chip">
23556          <div class="stat-chip-label">Input KSLOC</div>
23557          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23558          <div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted &mdash; blank lines and comments are excluded from this total.</div>
23559        </div>
23560      </div>
23561      <div class="cocomo-box-note" style="white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures &mdash; actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</div>
23562    </div>
23563    {% endif %}
23564
23565    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23566    <div class="cocomo-box" style="margin-top:24px;">
23567      <div class="cocomo-box-head">
23568        <span class="cocomo-box-title">Tests &amp; Coverage</span>
23569        {% if has_coverage_data %}
23570        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23571          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23572        </span>
23573        {% endif %}
23574      </div>
23575      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23576        <div class="stat-chip">
23577          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23578          <div class="stat-chip-label">Test Functions</div>
23579          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23580        </div>
23581        <div class="stat-chip">
23582          {% if has_coverage_data %}
23583          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23584          {% else %}
23585          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23586          {% endif %}
23587          <div class="stat-chip-label">Line Coverage</div>
23588          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23589        </div>
23590        <div class="stat-chip">
23591          {% if !cov_fn_pct.is_empty() %}
23592          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23593          {% else %}
23594          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23595          {% endif %}
23596          <div class="stat-chip-label">Fn Coverage</div>
23597          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23598        </div>
23599        <div class="stat-chip">
23600          {% if !cov_branch_pct.is_empty() %}
23601          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23602          {% else %}
23603          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23604          {% endif %}
23605          <div class="stat-chip-label">Branch Coverage</div>
23606          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23607        </div>
23608      </div>
23609      {% if has_coverage_data %}
23610      <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> &nbsp;&middot;&nbsp; Open the full HTML report for a per-file breakdown.</div>
23611      {% else %}
23612      <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path &lt;coverage.info&gt;</code> to populate this section.</div>
23613      {% endif %}
23614    </div>
23615
23616    <div class="section-pair">
23617    <section class="panel">
23618        <div class="toolbar-row">
23619          <div>
23620            <h2>Language Breakdown</h2>
23621            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23622          </div>
23623          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23624        </div>
23625        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23626    </section>
23627
23628    <section class="panel r-chart-section">
23629      <div class="toolbar-row" style="margin-bottom:16px;">
23630        <div>
23631          <h2>Visualizations</h2>
23632          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23633        </div>
23634      </div>
23635
23636      <div class="r-viz-grid">
23637        <div class="r-viz-card">
23638          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23639            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23640            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23641          </div>
23642          <div class="r-chart-tab-bar">
23643            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23644            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23645          </div>
23646          <div class="r-chart-container" id="r-composition-chart"></div>
23647        </div>
23648        <div class="r-viz-card">
23649          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23650            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23651            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23652          </div>
23653          <div class="r-chart-container" id="r-scatter-chart"></div>
23654        </div>
23655        {% if has_semantic_data %}
23656        <div class="r-viz-card">
23657          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23658            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23659            <select class="r-chart-select" id="r-semantic-metric">
23660              <option value="functions">Functions</option>
23661              <option value="classes">Classes</option>
23662              <option value="variables">Variables</option>
23663              <option value="imports">Imports</option>
23664              <option value="tests">Tests</option>
23665            </select>
23666            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23667          </div>
23668          <div class="r-chart-container" id="r-semantic-chart"></div>
23669        </div>
23670        {% endif %}
23671        <div class="r-viz-card">
23672          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23673            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23674            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23675          </div>
23676          <div class="r-chart-container" id="r-density-chart"></div>
23677        </div>
23678        <div class="r-viz-card">
23679          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23680            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23681            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23682          </div>
23683          <div class="r-chart-container" id="r-avglines-chart"></div>
23684        </div>
23685        <div class="r-viz-card">
23686          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23687            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23688            <select class="r-chart-select" id="r-sub-metric">
23689              <option value="code">Code Lines</option>
23690              <option value="comment">Comments</option>
23691              <option value="blank">Blank Lines</option>
23692              <option value="physical">Physical Lines</option>
23693              <option value="files">Files</option>
23694            </select>
23695            <select class="r-chart-select" id="r-sub-sort">
23696              <option value="desc">Value ↓</option>
23697              <option value="asc">Value ↑</option>
23698              <option value="name">Name A→Z</option>
23699            </select>
23700            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23701          </div>
23702          <div class="r-chart-container" id="r-submodule-chart"></div>
23703        </div>
23704      </div>
23705
23706    </section>
23707    </div>
23708
23709  </div>
23710
23711  <div id="r-tt" aria-hidden="true"></div>
23712
23713  <script nonce="{{ csp_nonce }}">
23714    (function () {
23715      var body = document.body;
23716      var themeToggle = document.getElementById('theme-toggle');
23717      var storageKey = 'oxide-sloc-theme';
23718
23719      function applyTheme(theme) {
23720        body.classList.toggle('dark-theme', theme === 'dark');
23721      }
23722
23723      function loadSavedTheme() {
23724        try {
23725          var saved = localStorage.getItem(storageKey);
23726          if (saved === 'dark' || saved === 'light') {
23727            applyTheme(saved);
23728          }
23729        } catch (e) {}
23730      }
23731
23732      if (themeToggle) {
23733        themeToggle.addEventListener('click', function () {
23734          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23735          applyTheme(nextTheme);
23736          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23737        });
23738      }
23739
23740      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23741        button.addEventListener('click', function () {
23742          var value = button.getAttribute('data-copy-value') || '';
23743          if (!value) return;
23744          var originalText = button.textContent;
23745          function flashSuccess() {
23746            button.textContent = 'Copied!';
23747            setTimeout(function () { button.textContent = originalText; }, 1800);
23748          }
23749          function flashFail() {
23750            button.textContent = 'Copy failed';
23751            setTimeout(function () { button.textContent = originalText; }, 2000);
23752          }
23753          if (navigator.clipboard && navigator.clipboard.writeText) {
23754            navigator.clipboard.writeText(value).then(flashSuccess, function () {
23755              fallbackCopy(value, flashSuccess, flashFail);
23756            });
23757          } else {
23758            fallbackCopy(value, flashSuccess, flashFail);
23759          }
23760        });
23761      });
23762      function fallbackCopy(text, onSuccess, onFail) {
23763        try {
23764          var ta = document.createElement('textarea');
23765          ta.value = text;
23766          ta.style.position = 'fixed';
23767          ta.style.top = '-9999px';
23768          ta.style.left = '-9999px';
23769          document.body.appendChild(ta);
23770          ta.focus();
23771          ta.select();
23772          var ok = document.execCommand('copy');
23773          document.body.removeChild(ta);
23774          if (ok) { onSuccess(); } else { onFail(); }
23775        } catch (e) { onFail(); }
23776      }
23777
23778      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23779        btn.addEventListener('click', function () {
23780          var folder = btn.getAttribute('data-folder') || '';
23781          if (!folder) return;
23782          var orig = btn.textContent;
23783          fetch('/open-path?path=' + encodeURIComponent(folder))
23784            .then(function (r) { return r.json(); })
23785            .then(function (d) {
23786              if (d && d.server_mode_disabled) {
23787                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23788              } else if (d && d.ok) {
23789                btn.textContent = 'Opened!';
23790                setTimeout(function () { btn.textContent = orig; }, 1800);
23791              }
23792            })
23793            .catch(function () {
23794              btn.textContent = 'Failed';
23795              setTimeout(function () { btn.textContent = orig; }, 2000);
23796            });
23797        });
23798      });
23799
23800      loadSavedTheme();
23801
23802      // ── Compact number formatting for stat chips ──────────────────────────
23803      (function(){
23804        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();}
23805        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23806          var raw=parseInt(chip.getAttribute('data-raw'),10);
23807          if(isNaN(raw))return;
23808          var valEl=chip.querySelector('.stat-chip-val');
23809          if(valEl)valEl.textContent=fmt(raw);
23810          var exactEl=chip.querySelector('.stat-chip-exact');
23811          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23812        });
23813        // Code density chip
23814        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23815          var code=parseInt(chip.getAttribute('data-code'),10);
23816          var phys=parseInt(chip.getAttribute('data-physical'),10);
23817          if(isNaN(code)||isNaN(phys)||phys===0)return;
23818          var pct=(code/phys*100).toFixed(1)+'%';
23819          var valEl=chip.querySelector('.stat-chip-val');
23820          if(valEl)valEl.textContent=pct;
23821        });
23822        // Populate author handle from data-author attribute
23823        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23824          var author=chip.getAttribute('data-author');
23825          var el=chip.querySelector('.author-handle');
23826          if(el)el.textContent='/'+author.replace(/\s+/g,'');
23827        });
23828        // Click-to-copy on run-id-chip elements
23829        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23830          chip.addEventListener('click',function(){
23831            var val=chip.getAttribute('data-copy');
23832            if(!val)return;
23833            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23834            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);}
23835            chip.classList.add('chip-copied-flash');
23836            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23837          });
23838        });
23839        // Format delta card values with data-raw using comma-separated full numbers
23840        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23841          var raw=parseInt(card.getAttribute('data-raw'),10);
23842          if(isNaN(raw))return;
23843          var valEl=card.querySelector('.delta-card-val');
23844          if(valEl)valEl.textContent=raw.toLocaleString();
23845        });
23846        // Format code-before / code-now numbers in the compare banner stats line
23847        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23848          var raw=parseInt(el.getAttribute('data-raw'),10);
23849          if(!isNaN(raw))el.textContent=raw.toLocaleString();
23850        });
23851      })();
23852
23853      // ── Shared tooltip for all result-page charts ─────────────────────────
23854      var rTT=(function(){
23855        var el=document.getElementById('r-tt');
23856        if(!el)return{s:function(){},h:function(){},m:function(){}};
23857        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23858        function hide(){el.style.display='none';}
23859        function move(e){
23860          var x=e.clientX+16,y=e.clientY-12;
23861          var r=el.getBoundingClientRect();
23862          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23863          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23864          el.style.left=x+'px';el.style.top=y+'px';
23865        }
23866        return{s:show,h:hide,m:move};
23867      })();
23868      window.rTT=rTT;
23869
23870      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23871      (function(){
23872        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23873        document.addEventListener('mouseover',function(e){
23874          var t=e.target;
23875          while(t&&t.getAttribute){
23876            var l=t.getAttribute('data-ttl');
23877            if(l!==null){
23878              var v=t.getAttribute('data-ttv')||'';
23879              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23880              return;
23881            }
23882            t=t.parentNode;
23883          }
23884        });
23885        document.addEventListener('mouseout',function(e){
23886          var t=e.target;
23887          while(t&&t.getAttribute){
23888            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23889            t=t.parentNode;
23890          }
23891        });
23892        document.addEventListener('mousemove',function(e){
23893          var el=document.getElementById('r-tt');
23894          if(el&&el.style.display!=='none')rTT.m(e);
23895        });
23896        window.addEventListener('blur',function(){rTT.h();});
23897        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23898      })();
23899
23900      // ── Language overview charts ───────────────────────────────────────────
23901      (function(){
23902        var D={{ lang_chart_json|safe }};
23903        if(!D||!D.length)return;
23904        var el=document.getElementById('result-lang-charts');
23905        if(!el)return;
23906        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23907        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23908        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23909        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();}
23910        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23911        function px(n){return Math.round(n);}
23912        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+'"';}
23913        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23914        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23915        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23916        function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
23917        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23918
23919        // Donut chart — height matches the stacked-bar chart so both panels align
23920        var rHb_d=28;
23921        var DH=Math.max(220,D.length*rHb_d+32);
23922        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23923        var legX=208,DW=395;
23924        var legCount=D.length;
23925        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23926        var legYStart=Math.round((DH-legCount*legSpacing)/2);
23927        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">';
23928        if(D.length===1){
23929          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23930          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+'"/>';
23931        } else {
23932          var smalls=[];
23933          var ang=-Math.PI/2;
23934          D.forEach(function(d,i){
23935            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23936            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23937            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23938            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23939            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23940            var pct=Math.round(d.code/tot*100);
23941            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"/>';
23942            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
23943            ang+=sw;
23944          });
23945          // Small slices (<5%) get outside labels positioned near each slice's own
23946          // angular position (a slice on the left gets its label/leader on the left),
23947          // then nudged apart horizontally so text never overlaps. Leader lines point
23948          // from each slice to its label. Horizontal text keeps long names legible;
23949          // the whole SVG scales up in Full View so these stay readable there too.
23950          if(smalls.length){
23951            smalls.sort(function(a,b){return a.mAng-b.mAng;});
23952            var sPad=6,sRowY=11;
23953            smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
23954            for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
23955            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23956            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23957            smalls.forEach(function(sm){
23958              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23959              ds+='<line x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
23960              ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
23961            });
23962          }
23963        }
23964        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23965        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23966        D.forEach(function(d,i){
23967          var ly=legYStart+i*legSpacing;
23968          var pctL=Math.round(d.code/tot*100);
23969          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23970          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23971          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23972          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23973          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23974          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23975          ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
23976          ds+='</g>';
23977        });
23978        ds+='</svg>';
23979
23980        // Horizontal stacked-bar chart — fills container width
23981        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23982        var LW=108,BW=260,svgW=LW+BW+68;
23983        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23984        var barBH=Math.min(32,Math.round(barRhb*0.7));
23985        var SH=DH;
23986        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23987        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">';
23988        D.forEach(function(d,i){
23989          var y=barTopPad+i*barRhb,x=LW;
23990          var phys=d.physical||d.code+d.comments+d.blanks;
23991          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23992          var lmid=y+barBH/2+4;
23993          // Combined breakdown shown when hovering the row, the language name, or the
23994          // total at the bar end (\n becomes a line break in the tooltip).
23995          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23996          bs+='<g class="lang-bar-row">';
23997          // Hit area ends just past the total label so empty space to the right of the
23998          // bar does not trigger the tooltip — only the name, bar and total are hot.
23999          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24000          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24001          bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24002          if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'" rx="0"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
24003          if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'" rx="0"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
24004          if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'" rx="0"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
24005          bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
24006          bs+='</g>';
24007        });
24008        var ly=SH-14;
24009        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24010        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24011        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24012        var totAll=totC+totCm+totBl||1;
24013        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24014        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24015        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24016        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24017        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24018        bs+='<g data-kind="code" style="cursor:pointer;">'
24019          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24020          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24021          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24022          +'</g>';
24023        bs+='<g data-kind="comment" style="cursor:pointer;">'
24024          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24025          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24026          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24027          +'</g>';
24028        bs+='<g data-kind="blank" style="cursor:pointer;">'
24029          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24030          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24031          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24032          +'</g>';
24033        bs+='</svg>';
24034        el.innerHTML='<div class="r-lang-overview">'+
24035          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24036          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24037        '</div>';
24038        function wireDonutLegend(svg){
24039          if(!svg)return;
24040          var paths=svg.querySelectorAll('path[data-lang]');
24041          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';}}}
24042          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
24043          svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
24044          svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
24045          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24046        }
24047        function wireMixLegend(svg){
24048          if(!svg)return;
24049          var legGs=svg.querySelectorAll('g[data-kind]');
24050          var allRects=svg.querySelectorAll('rect[data-kind]');
24051          if(!legGs.length)return;
24052          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';}}
24053          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='';}}
24054          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]);}
24055        }
24056        wireDonutLegend(el.querySelector('svg'));
24057        wireMixLegend(el.querySelectorAll('svg')[1]);
24058
24059        // ── Language breakdown Full View expand ─────────────────────────────────
24060        var langOvBtn=document.getElementById('result-lang-overview-expand');
24061        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24062          var src=document.getElementById('result-lang-charts');
24063          if(!src)return;
24064          var overlay=document.createElement('div');
24065          overlay.className='r-chart-modal-overlay';
24066          overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
24067          document.body.appendChild(overlay);
24068          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24069          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24070          var wrap=document.getElementById('result-lang-overview-modal-wrap');
24071          if(wrap){
24072            wrap.innerHTML=src.innerHTML;
24073            var svgs=wrap.querySelectorAll('svg');
24074            for(var i=0;i<svgs.length;i++){
24075              svgs[i].removeAttribute('width');
24076              svgs[i].removeAttribute('height');
24077              svgs[i].style.cssText='display:block;width:100%;height:auto;';
24078            }
24079            var ov=wrap.querySelector('.r-lang-overview');
24080            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24081            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24082            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24083            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24084            wireDonutLegend(wrap.querySelector('svg'));
24085            wireMixLegend(wrap.querySelectorAll('svg')[1]);
24086            requestAnimationFrame(function(){
24087              var ss=wrap.querySelectorAll('svg');
24088              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%;';}}
24089            });
24090          }
24091        });}
24092      })();
24093
24094      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24095      (function(){
24096        var LANG_D={{ lang_chart_json|safe }};
24097        var SCAT_D={{ scatter_chart_json|safe }};
24098        var SEM_D={{ semantic_chart_json|safe }};
24099        var SUB_D={{ submodule_chart_json|safe }};
24100        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24101        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24102        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();}
24103        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24104        function px(n){return Math.round(n);}
24105        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+'"';}
24106        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24107        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24108        // rather than disappear; the SVG scales up in Full View).
24109        function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
24110
24111        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24112        function renderCompositionInEl(el,mode,shOvr){
24113          if(!el||!LANG_D||!LANG_D.length)return;
24114          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24115          var LW=110,SH=shOvr||300;
24116          var svgW=Math.max(320,el.offsetWidth||480);
24117          var BW=Math.max(120,svgW-LW-80);
24118          var legendH=24,topPad=4;
24119          var n=LANG_D.length||1;
24120          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24121          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24122          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">';
24123          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24124          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24125          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24126          var totAll2=totC2+totCm2+totBl2||1;
24127          if(mode==='pct'){
24128            LANG_D.forEach(function(d,i){
24129              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24130              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24131              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24132              var lmid=y+Math.floor(bH/2)+4;
24133              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24134              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24135              if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24136              if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24137              if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24138              var pct=Math.round((d.code||0)/tot2*100);
24139              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+pct+'%</text>';
24140            });
24141          } else {
24142            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24143            LANG_D.forEach(function(d,i){
24144              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24145              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24146              var lmid=y+Math.floor(bH/2)+4;
24147              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
24148              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24149              if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24150              if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24151              if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24152              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
24153            });
24154          }
24155          var ly=SH-legendH+4;
24156          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24157          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24158          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24159          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24160          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24161          s+='<g data-kind="code" style="cursor:pointer;">'
24162            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24163            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24164            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24165            +'</g>';
24166          s+='<g data-kind="comment" style="cursor:pointer;">'
24167            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24168            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24169            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24170            +'</g>';
24171          s+='<g data-kind="blank" style="cursor:pointer;">'
24172            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24173            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24174            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24175            +'</g>';
24176          s+='</svg>';
24177          el.innerHTML=s;
24178          wireMixLegendEl(el);
24179        }
24180        function wireMixLegendEl(container){
24181          var svg=container&&container.querySelector('svg');
24182          if(!svg)return;
24183          var legGs=svg.querySelectorAll('g[data-kind]');
24184          var allRects=svg.querySelectorAll('rect[data-kind]');
24185          if(!legGs.length)return;
24186          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';}}
24187          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='';}}
24188          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]);}
24189        }
24190        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24191        renderComposition('abs');
24192        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24193          btn.addEventListener('click',function(){
24194            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24195            btn.classList.add('active');
24196            renderComposition(btn.getAttribute('data-rcomp'));
24197          });
24198        });
24199
24200        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24201        function wireScatterLegend(container){
24202          var svg=container&&container.querySelector('svg');
24203          if(!svg)return;
24204          var legGs=svg.querySelectorAll('g[data-lang]');
24205          var circs=svg.querySelectorAll('circle[data-lang]');
24206          if(!legGs.length)return;
24207          function hl(lang){for(var i=0;i<circs.length;i++){var c=circs[i];if(c.getAttribute('data-lang')===lang){c.style.opacity='1';c.style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.28))';}else{c.style.opacity='0.12';c.style.filter='none';}}
24208            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24209          function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24210          for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hl(g.getAttribute('data-lang'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24211        }
24212        function renderScatterInEl(el,hOvr){
24213          if(!el||!SCAT_D||!SCAT_D.length)return;
24214          var n=SCAT_D.length;
24215          var H=hOvr||300,PL=52,PB=36,PT=44;
24216          var W=Math.max(320,el.offsetWidth||480);
24217          var cH=H-PT-PB;
24218          // Legend: max 2 columns, fills vertical space. The compact card shows the
24219          // top languages by code lines plus a "+N more" row linking to Full View;
24220          // Full View (hOvr set) shows every language across up to 2 tall columns.
24221          var compact=!hOvr;
24222          var availH=Math.max(120,H-24);
24223          var rowsFit=Math.max(2,Math.floor(availH/18));
24224          var legTrunc=compact&&(n>2*rowsFit);
24225          var legShown=legTrunc?(2*rowsFit-1):n;
24226          var legTotal=legTrunc?(2*rowsFit):n;
24227          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24228          var legPerCol=Math.ceil(legTotal/legCols);
24229          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24230          var legColW=hOvr?144:130;
24231          var LG=26;
24232          var legW=legCols*legColW;
24233          var cW=W-PL-LG-legW;
24234          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24235          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24236          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24237          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24238          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24239          var logMaxF=Math.log1p(maxF);
24240          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">';
24241          // Y grid lines (linear)
24242          [0,0.25,0.5,0.75,1].forEach(function(t){
24243            var y=PT+cH*(1-t);
24244            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24245            if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(Math.round(maxC*t))+'</text>';
24246          });
24247          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24248          [0,0.25,0.5,0.75,1].forEach(function(t){
24249            var x=PL+cW*t;
24250            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24251            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24252            if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(xVal)+'</text>';
24253          });
24254          // Full View (hOvr set) has the vertical room to show the per-bubble value
24255          // line; the compact card shows only the language label to avoid the
24256          // overlapping-label clutter seen when bubbles cluster together.
24257          var showVal=!!hOvr;
24258          SCAT_D.forEach(function(d,i){
24259            // X uses log1p so outlier languages (many files) don't push others to the far left
24260            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24261            var cy2=PT+cH-d.code/maxC*cH;
24262            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24263            s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' data-lang="'+esc(d.lang)+'" cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
24264            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24265            if(showVal){
24266              var ty2=Math.max(24,px(cy2)-px(r)-3);
24267              var ty1=Math.max(12,ty2-14);
24268              s+='<text x="'+px(cx2)+'" y="'+ty1+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24269              s+='<text x="'+px(cx2)+'" y="'+ty2+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor" opacity="0.88" style="pointer-events:none;">'+fmt(d.code)+'</text>';
24270            }else{
24271              var ly2=Math.max(12,px(cy2)-px(r)-3);
24272              s+='<text x="'+px(cx2)+'" y="'+ly2+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24273            }
24274          });
24275          s+='<text x="'+(PL+cW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75">Files Analyzed</text>';
24276          s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
24277          // Legend (right side — top languages, max 2 columns, fills height)
24278          var legX=PL+cW+LG;
24279          var legBlockH=legPerCol*legRowH;
24280          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24281          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24282          for(var lk=0;lk<legShown;lk++){
24283            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24284            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24285            s+='<g data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" style="cursor:pointer;">';
24286            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24287            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24288            s+='<text x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" style="pointer-events:none;">'+esc(ld.lang)+'</text>';
24289            s+='</g>';
24290          }
24291          if(legTrunc){
24292            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24293            s+='<g data-more="1" style="cursor:pointer;">';
24294            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24295            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24296            s+='<text x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" style="pointer-events:none;">+'+(n-legShown)+' more</text>';
24297            s+='</g>';
24298          }
24299          s+='</svg>';
24300          el.innerHTML=s;
24301          wireScatterLegend(el);
24302          var moreEl=el.querySelector('g[data-more]');
24303          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24304        }
24305        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24306
24307        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24308        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24309        // the old vertical column layout on wide containers.
24310        function renderSemanticInEl(el,key,sh){
24311          if(!el||!SEM_D||!SEM_D.length)return;
24312          var n2=SEM_D.length||1;
24313          var LW=112,SH=sh||Math.max(180,n2*28+26);
24314          var svgW=Math.max(320,el.offsetWidth||480);
24315          var BW=Math.max(120,svgW-LW-80);
24316          var topPad=4,botPad=14;
24317          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24318          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24319          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24320          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">';
24321          SEM_D.forEach(function(d,i){
24322            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24323            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>';
24324            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"/>';
24325            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>';
24326          });
24327          s+='</svg>';
24328          el.innerHTML=s;
24329        }
24330        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24331        var semSel=document.getElementById('r-semantic-metric');
24332        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24333        var semExpand=document.getElementById('r-semantic-expand');
24334        if(semExpand){
24335          semExpand.addEventListener('click',function(){
24336            var key=semSel?semSel.value:'functions';
24337            var n=SEM_D.length||1;
24338            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24339            var modalH=Math.min(Math.max(360,n*38+60),maxH);
24340            var overlay=document.createElement('div');
24341            overlay.className='r-chart-modal-overlay';
24342            var optHtml=
24343              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24344              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24345              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24346              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24347              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24348            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics \u2014 Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" class="r-expand-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
24349            document.body.appendChild(overlay);
24350            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24351            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24352            var modalEl=document.getElementById('r-sem-modal-chart');
24353            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24354            var modalSel=document.getElementById('r-sem-modal-metric');
24355            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24356          });
24357        }
24358
24359        // ── Expand buttons: re-render charts at large size inside modal ──────────
24360        (function(){
24361          function makeExpandModal(title,mH,subtitle,ctrlHtml){
24362            var overlay=document.createElement('div');
24363            overlay.className='r-chart-modal-overlay';
24364            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24365            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24366            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>';
24367            document.body.appendChild(overlay);
24368            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24369            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24370            return overlay.querySelector('.r-expand-modal-chart');
24371          }
24372          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24373          var compExpandBtn=document.getElementById('r-composition-expand');
24374          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24375            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24376            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24377            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24378              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24379            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24380            if(wrap){
24381              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24382              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24383                btn.addEventListener('click',function(){
24384                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24385                  btn.classList.add('active');
24386                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24387                });
24388              });
24389            }
24390          });}
24391          var scatExpandBtn=document.getElementById('r-scatter-expand');
24392          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24393            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24394            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24395          });}
24396          var densExpandBtn=document.getElementById('r-density-expand');
24397          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24398            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24399            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24400            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24401          });}
24402          var avgExpandBtn=document.getElementById('r-avglines-expand');
24403          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24404            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24405            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24406            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24407          });}
24408          var subExpandBtn=document.getElementById('r-submodule-expand');
24409          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24410            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24411            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24412            var metCtrl=
24413              '<select class="r-chart-select" id="r-sub-modal-metric">'
24414              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24415              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24416              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24417              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24418              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24419              +'</select>';
24420            var sortCtrl=
24421              '<select class="r-chart-select" id="r-sub-modal-sort">'
24422              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24423              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24424              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24425              +'</select>';
24426            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24427            if(wrap){
24428              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24429              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24430              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24431              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24432              if(mSub)mSub.addEventListener('change',reRenderSub);
24433              if(mSort)mSort.addEventListener('change',reRenderSub);
24434            }
24435          });}
24436        })();
24437
24438        // ── Comment Density: comments / (code + comments) per language ───────────
24439        function renderDensityInEl(el,shOvr){
24440          if(!el||!LANG_D||!LANG_D.length)return;
24441          var n=LANG_D.length||1;
24442          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24443          var svgW=Math.max(320,el.offsetWidth||480);
24444          var BW=Math.max(120,svgW-LW-80);
24445          var topPad=4,botPad=26;
24446          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24447          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24448          var densities=LANG_D.map(function(d){
24449            var sig=(d.code||0)+(d.comments||0);
24450            return sig>0?(d.comments||0)/sig:0;
24451          });
24452          var maxDen=Math.max.apply(null,densities)||1;
24453          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">';
24454          LANG_D.forEach(function(d,i){
24455            var den=densities[i],bw=den/maxDen*BW;
24456            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24457            var pct=Math.round(den*100);
24458            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>';
24459            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"/>';
24460            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24461            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>';
24462          });
24463          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>';
24464          s+='</svg>';
24465          el.innerHTML=s;
24466        }
24467        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24468        renderDensity();
24469
24470        // ── Avg Lines per File: code / files per language ─────────────────────
24471        function renderAvgLinesInEl(el,shOvr){
24472          if(!el||!LANG_D||!LANG_D.length)return;
24473          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24474          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24475          var n=data.length||1;
24476          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24477          var svgW=Math.max(320,el.offsetWidth||480);
24478          var BW=Math.max(120,svgW-LW-80);
24479          var topPad=4,botPad=26;
24480          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24481          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24482          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24483          var maxAvg=Math.max.apply(null,avgs)||1;
24484          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">';
24485          data.forEach(function(d,i){
24486            var avg=avgs[i],bw=avg/maxAvg*BW;
24487            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24488            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>';
24489            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file \u00b7 '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24490            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24491            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>';
24492          });
24493          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>';
24494          s+='</svg>';
24495          el.innerHTML=s;
24496        }
24497        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24498        renderAvgLines();
24499
24500        // ── Repository Overview: overall row + per-submodule rows ────────────
24501        function renderSubmoduleInEl(el,key,sort,shOvr){
24502          if(!el)return;
24503          var overall={
24504            name:'Overall',
24505            code:{{ code_lines }},
24506            comment:{{ comment_lines }},
24507            blank:{{ blank_lines }},
24508            physical:{{ physical_lines }},
24509            files:{{ files_analyzed }},
24510            isOverall:true
24511          };
24512          var subs=SUB_D.slice();
24513          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24514          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24515          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24516          var data=[overall].concat(subs);
24517          var sepH=subs.length>0?14:0;
24518          var naturalH=data.length*32+sepH+16;
24519          var SH=shOvr||Math.max(100,naturalH);
24520          var svgW=Math.max(320,el.offsetWidth||480);
24521          var LW=116,BW=Math.max(200,svgW-LW-54);
24522          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24523          var OVERALL_COL='#6b7280';
24524          var topPad=4,botPad=8;
24525          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24526          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24527          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">';
24528          var yOff=topPad;
24529          data.forEach(function(d,i){
24530            var v=d[key]||0,bw=v/maxV*BW;
24531            var y=yOff+Math.floor((rowSlot-bH)/2);
24532            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24533            var label=d.name||d.path||'?';
24534            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
24535            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24536            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24537            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
24538            yOff+=rowSlot;
24539            if(d.isOverall&&subs.length>0){
24540              yOff+=sepH;
24541            }
24542          });
24543          s+='</svg>';
24544          el.innerHTML=s;
24545        }
24546        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24547        var subSel=document.getElementById('r-sub-metric');
24548        var sortSel=document.getElementById('r-sub-sort');
24549        renderSubmodule('code','desc');
24550        if(subSel){
24551          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24552          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24553        }
24554
24555        // Equalise heights within each chart row: if one chart in a grid row is taller
24556        // than its neighbour, re-render the shorter one at the taller height so bars fill
24557        // the available vertical space instead of leaving a gap.
24558        function syncRowHeights(){
24559          var avgEl=document.getElementById('r-avglines-chart');
24560          var subEl=document.getElementById('r-submodule-chart');
24561          if(avgEl&&subEl){
24562            var avgSvg=avgEl.querySelector('svg');
24563            var subSvg=subEl.querySelector('svg');
24564            if(avgSvg&&subSvg){
24565              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24566              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24567              var key=subSel?subSel.value||'code':'code';
24568              var sort=sortSel?sortSel.value:'desc';
24569              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24570              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24571            }
24572          }
24573          var semEl=document.getElementById('r-semantic-chart');
24574          var denEl=document.getElementById('r-density-chart');
24575          if(semEl&&denEl){
24576            var semSvg=semEl.querySelector('svg');
24577            var denSvg=denEl.querySelector('svg');
24578            if(semSvg&&denSvg){
24579              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24580              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24581              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24582              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24583            }
24584          }
24585        }
24586        syncRowHeights();
24587
24588        // Re-render all SVG charts when the window is resized so bars fill the card.
24589        var _rResizeTimer;
24590        window.addEventListener('resize',function(){
24591          clearTimeout(_rResizeTimer);
24592          _rResizeTimer=setTimeout(function(){
24593            var rcompBtn=document.querySelector('[data-rcomp].active');
24594            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24595            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24596            if(semSel)renderSemantic(semSel.value||'functions');
24597            renderDensity();
24598            renderAvgLines();
24599            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24600            syncRowHeights();
24601          },120);
24602        });
24603      })();
24604
24605      (function randomizeWatermarks() {
24606        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24607        if (!wms.length) return;
24608        var placed = [];
24609        function tooClose(top, left) {
24610          for (var i = 0; i < placed.length; i++) {
24611            var dt = Math.abs(placed[i][0] - top);
24612            var dl = Math.abs(placed[i][1] - left);
24613            if (dt < 20 && dl < 18) return true;
24614          }
24615          return false;
24616        }
24617        function pick(leftBand) {
24618          for (var attempt = 0; attempt < 50; attempt++) {
24619            var top = Math.random() * 85 + 5;
24620            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24621            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24622          }
24623          var top = Math.random() * 85 + 5;
24624          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24625          placed.push([top, left]);
24626          return [top, left];
24627        }
24628        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24629        var half = Math.floor(wms.length / 2);
24630        wms.forEach(function (img, i) {
24631          var pos = pick(i < half);
24632          var size = Math.floor(Math.random() * 100 + 160);
24633          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24634          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24635          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;
24636        });
24637      })();
24638
24639      (function spawnCodeParticles() {
24640        var container = document.getElementById('code-particles');
24641        if (!container) return;
24642        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'];
24643        for (var i = 0; i < 38; i++) {
24644          (function(idx) {
24645            var el = document.createElement('span');
24646            el.className = 'code-particle';
24647            el.textContent = snippets[idx % snippets.length];
24648            var left = Math.random() * 94 + 2;
24649            var top = Math.random() * 88 + 6;
24650            var dur = (Math.random() * 10 + 9).toFixed(1);
24651            var delay = (Math.random() * 18).toFixed(1);
24652            var rot = (Math.random() * 26 - 13).toFixed(1);
24653            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24654            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';
24655            container.appendChild(el);
24656          })(i);
24657        }
24658      })();
24659
24660      {% if pdf_generating %}
24661      // Poll for PDF readiness and swap the disabled button to a live link once done.
24662      (function() {
24663        var openBtn = document.getElementById('pdf-open-btn');
24664        var dlBtn = document.getElementById('pdf-download-btn');
24665        function checkPdf() {
24666          fetch('/api/runs/{{ run_id }}/pdf-status')
24667            .then(function(r) { return r.json(); })
24668            .then(function(d) {
24669              if (d.ready) {
24670                if (openBtn) {
24671                  var a = document.createElement('a');
24672                  a.className = 'button';
24673                  a.id = 'pdf-open-btn';
24674                  a.href = '/runs/pdf/{{ run_id }}';
24675                  a.target = '_blank';
24676                  a.rel = 'noopener';
24677                  a.textContent = 'Open PDF';
24678                  openBtn.replaceWith(a);
24679                }
24680                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24681              } else {
24682                setTimeout(checkPdf, 3000);
24683              }
24684            })
24685            .catch(function() { setTimeout(checkPdf, 5000); });
24686        }
24687        setTimeout(checkPdf, 3000);
24688      })();
24689      {% endif %}
24690
24691    })();
24692  </script>
24693  <script nonce="{{ csp_nonce }}">
24694  (function(){
24695    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'}];
24696    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);});}
24697    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24698    function init(){
24699      var btn=document.getElementById('settings-btn');if(!btn)return;
24700      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24701      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>';
24702      document.body.appendChild(m);
24703      var g=document.getElementById('scheme-grid');
24704      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);});
24705      var cl=document.getElementById('settings-close');
24706      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);
24707      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');});
24708      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24709      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24710    }
24711    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24712  }());
24713  </script>
24714  <footer class="site-footer">
24715    local code analysis - metrics, history and reports
24716    &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>
24717    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24718    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24719    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24720    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
24721  </footer>
24722  {% if confluence_configured %}
24723  <script nonce="{{ csp_nonce }}">
24724  (function() {
24725    var postBtn = document.getElementById('postConfluenceBtn');
24726    var copyBtn = document.getElementById('copyWikiBtn');
24727    var modal   = document.getElementById('confluenceModal');
24728    if (!postBtn || !modal) return;
24729
24730    postBtn.addEventListener('click', function() {
24731      document.getElementById('confStatus').style.display = 'none';
24732      modal.style.display = 'flex';
24733    });
24734    document.getElementById('confCancelBtn').addEventListener('click', function() {
24735      modal.style.display = 'none';
24736    });
24737    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24738
24739    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24740      var btn = this;
24741      btn.disabled = true;
24742      var status = document.getElementById('confStatus');
24743      status.style.display = 'block';
24744      status.style.background = '#dbeafe';
24745      status.style.color = '#1e40af';
24746      status.textContent = 'Posting to Confluence\u2026';
24747      var resp = await fetch('/api/confluence/post', {
24748        method: 'POST',
24749        headers: { 'Content-Type': 'application/json' },
24750        body: JSON.stringify({
24751          run_id: '{{ run_id }}',
24752          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24753          report_url: document.getElementById('confReportUrl').value.trim() || null
24754        })
24755      });
24756      var data = await resp.json();
24757      if (data.ok) {
24758        status.style.background = '#dcfce7'; status.style.color = '#166534';
24759        status.textContent = 'Posted! Page ID: ' + data.page_id;
24760      } else {
24761        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24762        status.textContent = 'Error: ' + (data.error || 'Unknown error');
24763      }
24764      btn.disabled = false;
24765    });
24766
24767    if (copyBtn) {
24768      copyBtn.addEventListener('click', async function() {
24769        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24770        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24771        var text = await resp.text();
24772        try {
24773          await navigator.clipboard.writeText(text);
24774          var orig = copyBtn.textContent;
24775          copyBtn.textContent = 'Copied!';
24776          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24777        } catch(e) {
24778          alert('Clipboard write failed \u2014 check browser permissions.');
24779        }
24780      });
24781    }
24782  })();
24783  </script>
24784  {% endif %}
24785  <script nonce="{{ csp_nonce }}">
24786  (function() {
24787    var deleteBtn = document.getElementById('delete-run-btn');
24788    var modal     = document.getElementById('delete-run-modal');
24789    var cancelBtn = document.getElementById('delete-run-cancel');
24790    var confirmBtn= document.getElementById('delete-run-confirm');
24791    if (!deleteBtn || !modal) return;
24792    deleteBtn.addEventListener('click', function() {
24793      document.getElementById('delete-run-status').style.display = 'none';
24794      modal.style.display = 'flex';
24795    });
24796    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24797    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24798    confirmBtn.addEventListener('click', async function() {
24799      confirmBtn.disabled = true;
24800      cancelBtn.disabled = true;
24801      var status = document.getElementById('delete-run-status');
24802      status.style.display = 'block';
24803      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24804      status.textContent = 'Deleting\u2026';
24805      try {
24806        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24807        if (resp.status === 204 || resp.ok) {
24808          status.style.background = '#dcfce7'; status.style.color = '#166534';
24809          status.textContent = 'Deleted. Redirecting\u2026';
24810          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24811        } else {
24812          var d = await resp.json().catch(function(){return {};});
24813          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24814          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24815          confirmBtn.disabled = false;
24816          cancelBtn.disabled = false;
24817        }
24818      } catch (e) {
24819        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24820        status.textContent = 'Network error: ' + String(e);
24821        confirmBtn.disabled = false;
24822        cancelBtn.disabled = false;
24823      }
24824    });
24825  })();
24826  </script>
24827  <script nonce="{{ csp_nonce }}">(function(){
24828    var bundleBtn = document.getElementById('download-bundle-btn');
24829    if (bundleBtn) {
24830      bundleBtn.addEventListener('click', function() {
24831        bundleBtn.disabled = true;
24832        var orig = bundleBtn.textContent;
24833        bundleBtn.textContent = 'Preparing\u2026';
24834        fetch('/api/runs/{{ run_id }}/bundle')
24835          .then(function(r) {
24836            if (!r.ok) throw new Error('HTTP ' + r.status);
24837            return r.blob();
24838          })
24839          .then(function(blob) {
24840            var url = URL.createObjectURL(blob);
24841            var a = document.createElement('a');
24842            a.href = url;
24843            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24844            document.body.appendChild(a);
24845            a.click();
24846            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24847            bundleBtn.disabled = false;
24848            bundleBtn.textContent = orig;
24849          })
24850          .catch(function(e) {
24851            bundleBtn.disabled = false;
24852            bundleBtn.textContent = orig;
24853            alert('Bundle download failed: ' + String(e));
24854          });
24855      });
24856    }
24857  })();</script>
24858  <script nonce="{{ csp_nonce }}">(function(){
24859    var dot=document.getElementById('status-dot');
24860    var pingEl=document.getElementById('server-ping-ms');
24861    var tipEl=document.getElementById('server-tip-ping');
24862    var fm=document.getElementById('footer-mode');
24863    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)';}}
24864    function doPing(){
24865      var t0=performance.now();
24866      fetch('/healthz',{cache:'no-store'})
24867        .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);})
24868        .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)';}});
24869    }
24870    doPing();
24871    setInterval(doPing,5000);
24872    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
24873  })();</script>
24874  <script nonce="{{ csp_nonce }}">(function(){var s=document.querySelector('.summary-strip-hero');if(!s)return;var pad=s.querySelector('.stat-chip-pad');var real=Array.prototype.slice.call(s.querySelectorAll('.stat-chip')).filter(function(el){return el!==pad;});if(!real.length)return;function upd(){var n=real.length;if(pad){if(n%2===1){pad.style.display='';n++;}else{pad.style.display='none';}}var perRow=window.innerWidth<=640?2:Math.ceil(n/2);s.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';}upd();window.addEventListener('resize',upd);})();</script>
24875  {% if let Some(banner) = report_header_footer %}
24876  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24877  {% endif %}
24878</body>
24879</html>
24880"##,
24881    ext = "html"
24882)]
24883// Template structs need many bool fields to pass Askama rendering flags.
24884#[allow(clippy::struct_excessive_bools)]
24885struct ResultTemplate {
24886    version: &'static str,
24887    report_title: String,
24888    project_path: String,
24889    output_dir: String,
24890    run_id: String,
24891    files_analyzed: u64,
24892    files_skipped: u64,
24893    physical_lines: u64,
24894    code_lines: u64,
24895    comment_lines: u64,
24896    blank_lines: u64,
24897    mixed_lines: u64,
24898    functions: u64,
24899    classes: u64,
24900    variables: u64,
24901    imports: u64,
24902    html_url: Option<String>,
24903    pdf_url: Option<String>,
24904    json_url: Option<String>,
24905    html_download_url: Option<String>,
24906    pdf_download_url: Option<String>,
24907    json_download_url: Option<String>,
24908    html_path: Option<String>,
24909    json_path: Option<String>,
24910    prev_run_id: Option<String>,
24911    prev_run_timestamp: Option<String>,
24912    prev_run_code_lines: Option<u64>,
24913    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
24914    prev_fa_str: String,
24915    prev_fs_str: String,
24916    prev_pl_str: String,
24917    prev_cl_str: String,
24918    prev_cml_str: String,
24919    prev_bl_str: String,
24920    // Signed change column for main metrics
24921    delta_fa_str: String,
24922    delta_fa_class: String,
24923    delta_fs_str: String,
24924    delta_fs_class: String,
24925    delta_pl_str: String,
24926    delta_pl_class: String,
24927    delta_cl_str: String,
24928    delta_cl_class: String,
24929    delta_cml_str: String,
24930    delta_cml_class: String,
24931    delta_bl_str: String,
24932    delta_bl_class: String,
24933    // delta vs previous scan
24934    delta_lines_added: Option<i64>,
24935    delta_lines_removed: Option<i64>,
24936    delta_lines_net_str: String,
24937    delta_lines_net_class: String,
24938    delta_files_added: Option<usize>,
24939    delta_files_removed: Option<usize>,
24940    delta_files_modified: Option<usize>,
24941    delta_files_unchanged: Option<usize>,
24942    delta_unmodified_lines: Option<u64>,
24943    // git context
24944    git_branch: Option<String>,
24945    git_branch_url: Option<String>,
24946    git_commit: Option<String>,
24947    git_commit_long: Option<String>,
24948    git_author: Option<String>,
24949    git_commit_url: Option<String>,
24950    // scan metadata for hero section
24951    scan_performed_by: String,
24952    scan_time_display: String,
24953    os_display: String,
24954    test_count: u64,
24955    // reserve "pad" card, revealed by JS only when the visible card count is odd
24956    test_assertion_count: u64,
24957    // history
24958    prev_scan_count: usize,
24959    current_scan_number: usize,
24960    // submodule breakdown (empty when not requested)
24961    submodule_rows: Vec<SubmoduleRow>,
24962    scan_config_url: String,
24963    lang_chart_json: String,
24964    // Askama reads these via proc-macro expansion; clippy can't trace through it.
24965    #[allow(dead_code)]
24966    scatter_chart_json: String,
24967    #[allow(dead_code)]
24968    semantic_chart_json: String,
24969    #[allow(dead_code)]
24970    submodule_chart_json: String,
24971    #[allow(dead_code)]
24972    has_submodule_data: bool,
24973    #[allow(dead_code)]
24974    has_semantic_data: bool,
24975    pdf_generating: bool,
24976    csp_nonce: String,
24977    /// Whether Confluence integration is configured — shows Post button when true.
24978    confluence_configured: bool,
24979    server_mode: bool,
24980    /// Header/footer identification banner, mirrored from the HTML/PDF report.
24981    report_header_footer: Option<String>,
24982    run_id_short: String,
24983    /// True when rendering a static offline file (index.html); hides server-only actions.
24984    #[allow(dead_code)]
24985    is_offline: bool,
24986    /// Total cyclomatic complexity score across all analyzed files.
24987    cyclomatic_complexity: u64,
24988    /// Logical SLOC (statement count) when available; None for unsupported languages.
24989    lsloc: Option<u64>,
24990    /// Unique Lines of Code across all analyzed files.
24991    uloc: u64,
24992    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
24993    dryness_pct_str: String,
24994    /// Number of duplicate file groups detected.
24995    duplicate_group_count: usize,
24996    /// Whether a COCOMO estimate is available to display.
24997    has_cocomo: bool,
24998    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
24999    cocomo_effort_str: String,
25000    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25001    cocomo_duration_str: String,
25002    /// Pre-formatted average team size, e.g. "2.32".
25003    cocomo_staff_str: String,
25004    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25005    cocomo_ksloc_str: String,
25006    /// COCOMO mode label shown in the card (e.g. "Organic").
25007    cocomo_mode_label: String,
25008    /// Tooltip text explaining the selected COCOMO mode.
25009    cocomo_mode_tooltip: String,
25010    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25011    complexity_alert: u32,
25012    /// Whether any file has coverage data attached.
25013    has_coverage_data: bool,
25014    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25015    cov_line_pct: String,
25016    /// Overall function coverage percentage string — empty if no data.
25017    cov_fn_pct: String,
25018    /// Overall branch coverage percentage string — empty if no branch data.
25019    cov_branch_pct: String,
25020    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25021    cov_lines_summary: String,
25022}
25023
25024#[derive(Template)]
25025#[template(
25026    source = r##"
25027<!doctype html>
25028<html lang="en">
25029<head>
25030  <meta charset="utf-8">
25031  <meta name="viewport" content="width=device-width, initial-scale=1">
25032  <title>OxideSLOC | Analyzing…</title>
25033  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25034  <style nonce="{{ csp_nonce }}">
25035    :root {
25036      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25037      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25038      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25039      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25040    }
25041    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25042    *{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;}
25043    .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);}
25044    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25045    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25046    .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));}
25047    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25048    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25049    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25050    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25051    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25052    @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; } }
25053    .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;}
25054    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25055    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25056    .page-body{padding:32px 24px 36px;}
25057    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25058    .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;}
25059    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25060    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25061    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25062    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25063    .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;}
25064    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25065    .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;}
25066    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25067    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25068    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25069    .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;}
25070    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25071    .hidden{display:none!important;}
25072    .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;}
25073    .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;}
25074    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25075    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25076    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25077    .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);}
25078    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25079    .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;}
25080    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25081    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25082    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25083    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25084    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25085    .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;}
25086    @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));}}
25087    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25088    .site-footer a{color:var(--muted);}
25089    .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;}
25090    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25091    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25092    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25093  </style>
25094</head>
25095<body>
25096  <div class="background-watermarks" aria-hidden="true">
25097    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25098    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25099    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25100    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25101    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25102    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25103  </div>
25104  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25105  <nav class="top-nav">
25106    <div class="top-nav-inner">
25107      <a href="/" class="brand">
25108        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25109        <div class="brand-copy">
25110          <h1 class="brand-title">OxideSLOC</h1>
25111          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25112        </div>
25113      </a>
25114      <div class="nav-right">
25115        <a class="nav-pill" href="/">Home</a>
25116        <div class="nav-dropdown">
25117          <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>
25118          <div class="nav-dropdown-menu">
25119            <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>
25120          </div>
25121        </div>
25122        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25123        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25124        <div class="nav-dropdown">
25125          <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>
25126          <div class="nav-dropdown-menu">
25127            <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>
25128          </div>
25129        </div>
25130        <div class="server-status-wrap" id="server-status-wrap">
25131          <div class="nav-pill server-online-pill" id="server-status-pill">
25132            <span class="status-dot" id="status-dot"></span>
25133            <span id="server-status-label">Server</span>
25134            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25135          </div>
25136          <div class="server-status-tip">
25137            OxideSLOC is running — accessible on your network.
25138            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25139          </div>
25140        </div>
25141        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25142          <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>
25143        </button>
25144        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25145          <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>
25146          <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>
25147        </button>
25148      </div>
25149    </div>
25150  </nav>
25151  <div class="page-body">
25152    <div class="wait-panel">
25153      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25154      <h2 class="wait-title">Analyzing your project…</h2>
25155      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25156      <div class="path-block">{{ project_path }}</div>
25157      <div class="metrics-row">
25158        <div class="metric-card">
25159          <div class="metric-label">Elapsed</div>
25160          <div class="metric-value" id="elapsed">0s</div>
25161        </div>
25162        <div class="metric-card">
25163          <div class="metric-label">Phase</div>
25164          <div class="metric-value" id="phase">Starting</div>
25165        </div>
25166        <div class="metric-card hidden" id="files-card">
25167          <div class="metric-label">Files</div>
25168          <div class="metric-value" id="files-progress">0</div>
25169        </div>
25170      </div>
25171      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25172      <div class="warn-slow hidden" id="warn-slow">
25173        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.
25174      </div>
25175      <div class="err-panel hidden" id="err-panel">
25176        <strong>Analysis failed</strong>
25177        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25178      </div>
25179      <div class="actions hidden" id="actions">
25180        <a href="/scan" class="btn-primary">Try Again</a>
25181        <a href="/view-reports" class="btn-outline">View Reports</a>
25182      </div>
25183    </div>
25184  </div>
25185  <script nonce="{{ csp_nonce }}">
25186    (function() {
25187      var WAIT_ID = {{ wait_id_json|safe }};
25188      var startTime = Date.now();
25189      var pollInterval = 1500;
25190      var retries = 0;
25191      var maxRetries = 5;
25192      var warnShown = false;
25193
25194      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();}
25195
25196      function elapsed() {
25197        return Math.floor((Date.now() - startTime) / 1000);
25198      }
25199
25200      function updateElapsed() {
25201        var s = elapsed();
25202        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25203      }
25204
25205      function setPhase(txt) {
25206        document.getElementById('phase').textContent = txt;
25207      }
25208
25209      var elapsedTimer = setInterval(updateElapsed, 1000);
25210
25211      function poll() {
25212        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25213          .then(function(r) {
25214            if (!r.ok) throw new Error('HTTP ' + r.status);
25215            return r.json();
25216          })
25217          .then(function(data) {
25218            retries = 0;
25219            if (data.state === 'complete') {
25220              clearInterval(elapsedTimer);
25221              setPhase('Done');
25222              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25223            } else if (data.state === 'failed') {
25224              clearInterval(elapsedTimer);
25225              setPhase('Failed');
25226              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25227              document.getElementById('err-panel').classList.remove('hidden');
25228              document.getElementById('actions').classList.remove('hidden');
25229            } else {
25230              // still running
25231              var s = elapsed();
25232              if (s > 90 && !warnShown) {
25233                warnShown = true;
25234                document.getElementById('warn-slow').classList.remove('hidden');
25235              }
25236              setPhase(data.phase || 'Running');
25237              var fd = data.files_done || 0, ft = data.files_total || 0;
25238              if (ft > 0) {
25239                var card = document.getElementById('files-card');
25240                if (card) card.classList.remove('hidden');
25241                var fp = document.getElementById('files-progress');
25242                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25243              }
25244              setTimeout(poll, pollInterval);
25245            }
25246          })
25247          .catch(function(err) {
25248            retries++;
25249            if (retries >= maxRetries) {
25250              clearInterval(elapsedTimer);
25251              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25252              document.getElementById('err-panel').classList.remove('hidden');
25253              document.getElementById('actions').classList.remove('hidden');
25254            } else {
25255              // exponential back-off capped at 8s
25256              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25257            }
25258          });
25259      }
25260
25261      setTimeout(poll, pollInterval);
25262
25263      // If the browser restores this page from bfcache (Back after viewing results),
25264      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25265      window.addEventListener("pageshow", function(e) {
25266        if (e.persisted) { setTimeout(poll, 200); }
25267      });
25268    })();
25269  </script>
25270  <footer class="site-footer">
25271    local code analysis - metrics, history and reports
25272    &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>
25273    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25274    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25275    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25276    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25277  </footer>
25278  <script nonce="{{ csp_nonce }}">
25279    (function(){
25280      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25281      if(s==="dark")b.classList.add("dark-theme");
25282      var tt=document.getElementById("theme-toggle");
25283      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25284    })();
25285    (function spawnCodeParticles(){
25286      var c=document.getElementById('code-particles');if(!c)return;
25287      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'];
25288      for(var i=0;i<32;i++){(function(idx){
25289        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25290        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25291        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25292        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25293        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25294        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25295        c.appendChild(el);
25296      })(i);}
25297    })();
25298    (function randomizeWatermarks(){
25299      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25300      var placed=[];
25301      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;}
25302      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];}
25303      var half=Math.floor(wms.length/2);
25304      wms.forEach(function(img,i){
25305        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25306        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25307        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25308        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25309        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25310        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25311      });
25312    })();
25313  </script>
25314  <script nonce="{{ csp_nonce }}">
25315  (function(){
25316    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'}];
25317    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);});}
25318    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25319    function init(){
25320      var btn=document.getElementById('settings-btn');if(!btn)return;
25321      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25322      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>';
25323      document.body.appendChild(m);
25324      var g=document.getElementById('scheme-grid');
25325      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);});
25326      var cl=document.getElementById('settings-close');
25327      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);
25328      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');});
25329      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25330      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25331    }
25332    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25333  }());
25334  </script>
25335  <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]';
25336  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
25337  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>
25338</body>
25339</html>
25340"##,
25341    ext = "html"
25342)]
25343struct ScanWaitTemplate {
25344    version: &'static str,
25345    wait_id_json: String,
25346    project_path: String,
25347    csp_nonce: String,
25348}
25349
25350#[derive(Template)]
25351#[template(
25352    source = r##"
25353<!doctype html>
25354<html lang="en">
25355<head>
25356  <meta charset="utf-8">
25357  <meta name="viewport" content="width=device-width, initial-scale=1">
25358  <title>OxideSLOC | Error</title>
25359  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25360  <style nonce="{{ csp_nonce }}">
25361    :root {
25362      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25363      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25364      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25365      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25366    }
25367    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25368    *{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;}
25369    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25370    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25371    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25372    .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);}
25373    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25374    .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));}
25375    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25376    .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;}
25377    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25378    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25379    @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; } }
25380    .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;}
25381    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25382    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25383    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25384    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25385    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25386    .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;}
25387    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25388    .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);}
25389    .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;}
25390    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25391    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25392    .settings-modal-body{padding:14px 16px 16px;}
25393    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25394    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25395    .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;}
25396    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25397    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25398    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25399    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25400    .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;}
25401    .tz-select:focus{border-color:var(--oxide);}
25402    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25403    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25404    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25405    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25406    .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;}
25407    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25408    .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);}
25409    .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;}
25410    .btn-secondary:hover{background:var(--line);}
25411    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25412    .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;}
25413    .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;}
25414    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25415    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25416    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25417    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25418    .bug-report-panel.open{display:flex;}
25419    .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;}
25420    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25421    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25422    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25423    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25424    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25425    .br-network-badge.online .br-net-dot{background:#2a6846;}
25426    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25427    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25428    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25429    .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;}
25430    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25431    .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;}
25432    .btn-sm:hover{background:var(--line);}
25433    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25434    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25435    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25436    .bug-report-hint a:hover{text-decoration:underline;}
25437    .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;}
25438    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25439    .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;}
25440    .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;}
25441    .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;}
25442    @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));}}
25443    .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;}
25444  </style>
25445</head>
25446<body>
25447  <div class="background-watermarks" aria-hidden="true">
25448    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25449    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25450    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25451    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25452    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25453    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25454  </div>
25455  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25456  <div class="top-nav">
25457    <div class="top-nav-inner">
25458      <a class="brand" href="/">
25459        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25460        <div class="brand-copy">
25461          <div class="brand-title">OxideSLOC</div>
25462          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25463        </div>
25464      </a>
25465      <div class="nav-right">
25466        <a class="nav-pill" href="/">Home</a>
25467        <div class="nav-dropdown">
25468          <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>
25469          <div class="nav-dropdown-menu">
25470            <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>
25471          </div>
25472        </div>
25473        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25474        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25475        <div class="nav-dropdown">
25476          <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>
25477          <div class="nav-dropdown-menu">
25478            <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>
25479          </div>
25480        </div>
25481        <div class="server-status-wrap" id="server-status-wrap">
25482          <div class="nav-pill server-online-pill" id="server-status-pill">
25483            <span class="status-dot" id="status-dot"></span>
25484            <span id="server-status-label">Server</span>
25485            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25486          </div>
25487          <div class="server-status-tip">
25488            OxideSLOC is running — accessible on your network.
25489            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25490          </div>
25491        </div>
25492        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25493          <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>
25494        </button>
25495        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25496          <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>
25497          <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>
25498        </button>
25499      </div>
25500    </div>
25501  </div>
25502
25503  <div class="page">
25504    <div class="panel">
25505      <h1>Error</h1>
25506      <div class="error-box" id="error-msg-text">{{ message }}</div>
25507      <div id="br-meta" hidden
25508        data-version="{{ version }}"
25509        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25510        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25511      <div class="actions">
25512        <a class="btn-primary" href="/scan">Back to setup</a>
25513        {% if let Some(report_url) = last_report_url %}
25514        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25515        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25516        {% else %}
25517        <a class="btn-secondary" href="/view-reports">View Reports</a>
25518        {% endif %}
25519      </div>
25520      <div class="bug-report-section" id="bug-report-section">
25521        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25522          <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>
25523          Generate Bug Report
25524          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25525        </button>
25526        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25527          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
25528          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
25529          <div class="bug-report-btns">
25530            <button type="button" class="btn-sm" id="bug-report-copy">
25531              <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>
25532              Copy to clipboard
25533            </button>
25534            <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;">
25535              <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>
25536              Open GitHub Issue
25537            </a>
25538            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25539              <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>
25540              Save as file
25541            </button>
25542          </div>
25543          <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>
25544          <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>
25545        </div>
25546      </div>
25547    </div>
25548  </div>
25549  <footer class="site-footer">
25550    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25551    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25552    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25553    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25554    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25555  </footer>
25556  <script nonce="{{ csp_nonce }}">(function(){
25557    var meta=document.getElementById('br-meta');
25558    var pre=document.getElementById('bug-report-pre');
25559    var copyBtn=document.getElementById('bug-report-copy');
25560    var trigger=document.getElementById('bug-report-trigger');
25561    var panel=document.getElementById('bug-report-panel');
25562    var networkBadge=document.getElementById('br-network-badge');
25563    var networkLabel=document.getElementById('br-network-label');
25564    var ghLink=document.getElementById('bug-report-github-link');
25565    var saveBtn=document.getElementById('bug-report-save');
25566    var hintOnline=document.getElementById('br-hint-online');
25567    var hintOffline=document.getElementById('br-hint-offline');
25568    if(!meta||!pre)return;
25569    var ver=meta.getAttribute('data-version')||'';
25570    var runId=meta.getAttribute('data-run-id')||'';
25571    var code=meta.getAttribute('data-error-code')||'';
25572    var msgEl=document.getElementById('error-msg-text');
25573    var msg=msgEl?msgEl.textContent.trim():'';
25574    function getBrowser(){
25575      var ua=navigator.userAgent;
25576      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25577      if(!m)return 'Unknown browser';
25578      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25579      return n+' '+m[2];
25580    }
25581    var lines=['oxide-sloc Bug Report','==============================',''];
25582    lines.push('App version:  v'+ver);
25583    if(code)lines.push('HTTP status:  '+code);
25584    if(runId)lines.push('Run ID:       '+runId);
25585    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
25586    lines.push('Timestamp:    '+new Date().toISOString());
25587    lines.push('Browser:      '+getBrowser());
25588    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
25589    lines.push('');
25590    lines.push('Error message:');
25591    lines.push(msg);
25592    lines.push('');
25593    lines.push('Steps to reproduce:');
25594    lines.push('  1. ');
25595    lines.push('');
25596    lines.push('Expected behavior:');
25597    lines.push('  ');
25598    pre.textContent=lines.join('\n');
25599    function applyNetwork(online){
25600      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25601      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25602      if(ghLink){
25603        if(online){
25604          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25605          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25606        }
25607        ghLink.style.display=online?'inline-flex':'none';
25608      }
25609      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25610      if(hintOnline)hintOnline.style.display=online?'block':'none';
25611      if(hintOffline)hintOffline.style.display=online?'none':'block';
25612    }
25613    applyNetwork(navigator.onLine);
25614    var probed=false;
25615    function probeNetwork(){
25616      if(probed)return;probed=true;
25617      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25618      var probeIdx=0;
25619      function tryNext(){
25620        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25621        var u=probeUrls[probeIdx++];
25622        var c2=new AbortController();
25623        var t2=setTimeout(function(){c2.abort();},4000);
25624        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25625          .then(function(){clearTimeout(t2);applyNetwork(true);})
25626          .catch(function(){clearTimeout(t2);tryNext();});
25627      }
25628      tryNext();
25629    }
25630    if(trigger&&panel){
25631      trigger.addEventListener('click',function(){
25632        var open=panel.classList.toggle('open');
25633        trigger.classList.toggle('open',open);
25634        trigger.setAttribute('aria-expanded',open?'true':'false');
25635        if(open)probeNetwork();
25636      });
25637    }
25638    if(copyBtn){
25639      copyBtn.addEventListener('click',function(){
25640        var txt=pre.textContent;
25641        if(navigator.clipboard&&navigator.clipboard.writeText){
25642          navigator.clipboard.writeText(txt).then(function(){
25643            copyBtn.textContent='\u2713 Copied!';
25644            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);
25645          });
25646        }else{
25647          var ta=document.createElement('textarea');
25648          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25649          document.body.appendChild(ta);ta.select();
25650          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25651          document.body.removeChild(ta);
25652        }
25653      });
25654    }
25655    if(saveBtn){
25656      saveBtn.addEventListener('click',function(){
25657        var txt=pre.textContent;
25658        var blob=new Blob([txt],{type:'text/plain'});
25659        var url=URL.createObjectURL(blob);
25660        var a=document.createElement('a');
25661        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25662        document.body.appendChild(a);a.click();
25663        document.body.removeChild(a);URL.revokeObjectURL(url);
25664      });
25665    }
25666  })();</script>
25667  <script nonce="{{ csp_nonce }}">
25668    (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");});})();
25669    (function spawnCodeParticles() {
25670      var container = document.getElementById('code-particles');
25671      if (!container) return;
25672      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'];
25673      for (var i = 0; i < 38; i++) {
25674        (function(idx) {
25675          var el = document.createElement('span');
25676          el.className = 'code-particle';
25677          el.textContent = snippets[idx % snippets.length];
25678          var left = Math.random() * 94 + 2;
25679          var top = Math.random() * 88 + 6;
25680          var dur = (Math.random() * 10 + 9).toFixed(1);
25681          var delay = (Math.random() * 18).toFixed(1);
25682          var rot = (Math.random() * 26 - 13).toFixed(1);
25683          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25684          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';
25685          container.appendChild(el);
25686        })(i);
25687      }
25688    })();
25689    (function randomizeWatermarks() {
25690      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25691      var placed = [];
25692      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; }
25693      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]; }
25694      var half = Math.floor(wms.length/2);
25695      wms.forEach(function(img, i) {
25696        var pos = pick(i < half);
25697        var w = Math.floor(Math.random()*60+80);
25698        var rot = (Math.random()*40-20).toFixed(1);
25699        var op = (Math.random()*0.08+0.05).toFixed(2);
25700        var animDur = (Math.random()*6+5).toFixed(1);
25701        var animDelay = (Math.random()*10).toFixed(1);
25702        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';
25703      });
25704    })();
25705  </script>
25706  <script nonce="{{ csp_nonce }}">
25707  (function(){
25708    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'}];
25709    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);});}
25710    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25711    function init(){
25712      var btn=document.getElementById('settings-btn');if(!btn)return;
25713      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25714      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>';
25715      document.body.appendChild(m);
25716      var g=document.getElementById('scheme-grid');
25717      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);});
25718      var cl=document.getElementById('settings-close');
25719      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);
25720      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');});
25721      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25722      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25723    }
25724    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25725  }());
25726  </script>
25727  <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]';
25728  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
25729  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>
25730</body>
25731</html>
25732"##,
25733    ext = "html"
25734)]
25735struct ErrorTemplate {
25736    message: String,
25737    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
25738    last_report_url: Option<String>,
25739    /// Label for the secondary action button; defaults to "View last report" when None.
25740    last_report_label: Option<String>,
25741    /// Run ID to surface in the bug report; `None` when not applicable.
25742    run_id: Option<String>,
25743    /// HTTP status code to surface in the bug report; `None` when unknown.
25744    error_code: Option<u16>,
25745    csp_nonce: String,
25746    version: &'static str,
25747}
25748
25749// ── LocateFileTemplate ────────────────────────────────────────────────────────
25750
25751#[derive(Template)]
25752#[template(
25753    source = r##"
25754<!doctype html>
25755<html lang="en">
25756<head>
25757  <meta charset="utf-8">
25758  <meta name="viewport" content="width=device-width, initial-scale=1">
25759  <title>OxideSLOC | Locate Report</title>
25760  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25761  <style nonce="{{ csp_nonce }}">
25762    :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);}
25763    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25764    *{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;}
25765    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25766    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25767    .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);}
25768    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25769    .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));}
25770    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25771    .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;}
25772    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25773    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25774    @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;}}
25775    .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;}
25776    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25777    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25778    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25779    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25780    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25781    .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;}
25782    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25783    .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);}
25784    .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;}
25785    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25786    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25787    .settings-modal-body{padding:14px 16px 16px;}
25788    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25789    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25790    .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;}
25791    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25792    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25793    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25794    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25795    .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;}
25796    .tz-select:focus{border-color:var(--oxide);}
25797    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25798    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25799    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25800    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25801    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25802    .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;}
25803    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25804    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25805    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25806    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25807    .locate-row{display:flex;gap:8px;align-items:stretch;}
25808    .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;}
25809    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25810    body.dark-theme .locate-input{background:var(--surface-2);}
25811    .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;}
25812    .warning-banner.show{display:flex;}
25813    .warning-banner svg{flex:0 0 auto;}
25814    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25815    .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;}
25816    .error-inline.show{display:flex;}
25817    .error-inline svg{flex:0 0 auto;margin-top:2px;}
25818    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25819    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25820    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25821    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25822    .err-kv-p{margin:0 0 4px;}
25823    .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;}
25824    .success-inline.show{display:flex;}
25825    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25826    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25827    .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;}
25828    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25829    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25830    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25831    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25832    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25833    .fh-row:last-child{border-bottom:none;}
25834    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25835    .fh-dir{font-weight:800;color:var(--text);}
25836    .fh-hl{color:var(--oxide);font-weight:700;}
25837    .fh-muted{color:var(--muted);}
25838    .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;}
25839    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25840    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25841    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25842    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25843    .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;}
25844    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25845    .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;}
25846    .btn-secondary:hover{background:var(--line);}
25847    .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;}
25848    .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;}
25849    .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;}
25850    @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));}}
25851    .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;}
25852    .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;}
25853    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25854  </style>
25855</head>
25856<body>
25857  <div class="background-watermarks" aria-hidden="true">
25858    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25859    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25860    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25861    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25862    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25863    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25864  </div>
25865  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25866  <div class="top-nav">
25867    <div class="top-nav-inner">
25868      <a class="brand" href="/">
25869        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25870        <div class="brand-copy">
25871          <div class="brand-title">OxideSLOC</div>
25872          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25873        </div>
25874      </a>
25875      <div class="nav-right">
25876        <a class="nav-pill" href="/">Home</a>
25877        <div class="nav-dropdown">
25878          <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>
25879          <div class="nav-dropdown-menu">
25880            <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>
25881          </div>
25882        </div>
25883        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25884        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25885        <div class="nav-dropdown">
25886          <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>
25887          <div class="nav-dropdown-menu">
25888            <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>
25889          </div>
25890        </div>
25891        <div class="server-status-wrap" id="server-status-wrap">
25892          <div class="nav-pill server-online-pill" id="server-status-pill">
25893            <span class="status-dot" id="status-dot"></span>
25894            <span id="server-status-label">Server</span>
25895            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25896          </div>
25897          <div class="server-status-tip">
25898            OxideSLOC is running &mdash; accessible on your network.
25899            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25900          </div>
25901        </div>
25902        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25903          <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>
25904        </button>
25905        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25906          <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>
25907          <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>
25908        </button>
25909      </div>
25910    </div>
25911  </div>
25912
25913  <div class="page">
25914    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25915    <div class="panel">
25916      <h1>Report File Not Found</h1>
25917      <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>
25918      <div class="field-label">Missing file</div>
25919      <div class="filename-chip">
25920        <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>
25921        {{ expected_filename }}
25922      </div>
25923      <div class="locate-section">
25924        <h2>Locate Scan Output Folder</h2>
25925        <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>
25926        <p>OxideSLOC will find the correct files inside automatically.</p>
25927        <div class="locate-row">
25928          <input type="text" id="locate-file-input"
25929                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25930                 class="locate-input" autocomplete="off" spellcheck="false">
25931          {% if !server_mode %}
25932          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
25933          {% endif %}
25934        </div>
25935        <div class="warning-banner" id="filename-warning">
25936          <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>
25937          <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>
25938        </div>
25939        <div class="error-inline" id="locate-error">
25940          <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>
25941          <span id="locate-error-text"></span>
25942        </div>
25943        <div class="success-inline" id="locate-success">
25944          <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>
25945          <span>Scan restored &mdash; loading report&hellip;</span>
25946        </div>
25947        <div class="btn-row">
25948          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25949          <a class="btn-secondary" href="/view-reports">View Reports</a>
25950        </div>
25951        <div class="folder-hint-shell">
25952          <div class="folder-hint-hdr">
25953            <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>
25954            Expected Folder Structure &mdash; Select the Top-Level Folder
25955          </div>
25956          <div class="folder-hint-body">
25957            <div class="fh-row">
25958              <span class="fh-tog">&#9658;</span>
25959              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
25960              <span class="fh-badge">&larr; select this</span>
25961            </div>
25962            <div class="fh-row fh-i1">
25963              <span class="fh-tog">&#9658;</span>
25964              <span class="fh-dir">html/</span>
25965            </div>
25966            <div class="fh-row fh-i2">
25967              <span class="fh-bul">&#8226;</span>
25968              <span class="fh-hl">{{ expected_filename }}</span>
25969            </div>
25970            <div class="fh-row fh-i1">
25971              <span class="fh-tog">&#9658;</span>
25972              <span class="fh-dir">json/</span>
25973            </div>
25974            <div class="fh-row fh-i2">
25975              <span class="fh-bul">&#8226;</span>
25976              <span class="fh-muted">result_*.json</span>
25977            </div>
25978            <div class="fh-row fh-i1">
25979              <span class="fh-tog">&#9658;</span>
25980              <span class="fh-dir">pdf/</span>
25981            </div>
25982            <div class="fh-row fh-i2">
25983              <span class="fh-bul">&#8226;</span>
25984              <span class="fh-muted">report_*.pdf</span>
25985            </div>
25986            <div class="fh-row fh-i1">
25987              <span class="fh-tog">&#9658;</span>
25988              <span class="fh-dir">excel/</span>
25989            </div>
25990            <div class="fh-row fh-i2">
25991              <span class="fh-bul">&#8226;</span>
25992              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
25993            </div>
25994          </div>
25995        </div>
25996      </div>
25997    </div>
25998  </div>
25999  <footer class="site-footer">
26000    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26001    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26002    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26003    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26004    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26005  </footer>
26006  <script nonce="{{ csp_nonce }}">(function(){
26007    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26008    if(s==="dark")b.classList.add("dark-theme");
26009    document.getElementById("theme-toggle").addEventListener("click",function(){
26010      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26011    });
26012  })();</script>
26013  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26014    var c=document.getElementById('code-particles');if(!c)return;
26015    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'];
26016    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);}
26017  })();
26018  (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>
26019  <script nonce="{{ csp_nonce }}">(function(){
26020    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'}];
26021    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);});}
26022    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26023    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');});}
26024    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26025  }());</script>
26026  <script nonce="{{ csp_nonce }}">(function(){
26027    var meta=document.getElementById('locate-meta');
26028    var inp=document.getElementById('locate-file-input');
26029    var browseBtn=document.getElementById('browse-locate-btn');
26030    var submitBtn=document.getElementById('locate-submit-btn');
26031    var warning=document.getElementById('filename-warning');
26032    var errBox=document.getElementById('locate-error');
26033    var errText=document.getElementById('locate-error-text');
26034    var okBox=document.getElementById('locate-success');
26035    var expected=meta?meta.getAttribute('data-expected'):'';
26036    var runId=meta?meta.getAttribute('data-run-id'):'';
26037    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26038    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26039    function showErr(msg){
26040      if(errText){
26041        errText.innerHTML='';
26042        var lines=msg.split('\n');
26043        var hasPairs=lines.some(function(l){return / : /.test(l);});
26044        if(!hasPairs){errText.textContent=msg;}
26045        else{
26046          var frag=document.createDocumentFragment();var tbl=null;
26047          lines.forEach(function(line){
26048            var m=line.match(/^(.*?) : (.*)$/);
26049            if(m){
26050              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26051              var tr=document.createElement('tr');
26052              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26053              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26054              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26055            } else {
26056              tbl=null;
26057              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26058            }
26059          });
26060          errText.appendChild(frag);
26061        }
26062      }
26063      if(errBox)errBox.classList.add('show');
26064      if(okBox)okBox.classList.remove('show');
26065    }
26066    function clearErr(){
26067      if(errBox)errBox.classList.remove('show');
26068      if(okBox)okBox.classList.remove('show');
26069    }
26070    function validate(){
26071      var val=inp?inp.value.trim():'';
26072      clearErr();
26073      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26074      if(submitBtn)submitBtn.disabled=false;
26075      if(warning){
26076        var name=basename(val);
26077        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26078        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26079        else warning.classList.remove('show');
26080      }
26081    }
26082    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26083    if(browseBtn){
26084      browseBtn.addEventListener('click',function(){
26085        browseBtn.disabled=true;browseBtn.textContent='...';
26086        fetch('/pick-directory')
26087          .then(function(r){return r.ok?r.json():{cancelled:true};})
26088          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26089          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26090      });
26091    }
26092    if(submitBtn){
26093      submitBtn.addEventListener('click',function(){
26094        var folder=inp?inp.value.trim():'';
26095        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26096        clearErr();
26097        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26098        var body=new URLSearchParams();
26099        body.set('file_path',folder);
26100        body.set('redirect_url',redirectUrl);
26101        body.set('expected_run_id',runId);
26102        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26103          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26104          .then(function(d){
26105            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26106            if(d&&d.ok){
26107              if(okBox)okBox.classList.add('show');
26108              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26109            } else {
26110              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26111            }
26112          })
26113          .catch(function(e){
26114            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26115            showErr('Network error: '+String(e));
26116          });
26117      });
26118    }
26119  })();</script>
26120  <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>
26121</body>
26122</html>
26123"##,
26124    ext = "html"
26125)]
26126struct LocateFileTemplate {
26127    run_id: String,
26128    artifact_type: String,
26129    expected_filename: String,
26130    server_mode: bool,
26131    csp_nonce: String,
26132    version: &'static str,
26133}
26134
26135// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26136
26137#[derive(Template)]
26138#[template(
26139    source = r##"
26140<!doctype html>
26141<html lang="en">
26142<head>
26143  <meta charset="utf-8">
26144  <meta name="viewport" content="width=device-width, initial-scale=1">
26145  <title>OxideSLOC | Locate Scan Files</title>
26146  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26147  <style nonce="{{ csp_nonce }}">
26148    :root {
26149      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26150      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26151      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26152      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26153    }
26154    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26155    *{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;}
26156    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26157    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26158    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26159    .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);}
26160    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26161    .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));}
26162    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26163    .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;}
26164    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26165    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26166    @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;}}
26167    .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;}
26168    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26169    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26170    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26171    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26172    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26173    .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;}
26174    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26175    .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);}
26176    .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;}
26177    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26178    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26179    .settings-modal-body{padding:14px 16px 16px;}
26180    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26181    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26182    .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;}
26183    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26184    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26185    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26186    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26187    .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;}
26188    .tz-select:focus{border-color:var(--oxide);}
26189    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26190    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26191    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26192    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26193    .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;}
26194    .error-box.hidden{display:none;}
26195    .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;}
26196    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26197    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26198    .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;}
26199    .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;}
26200    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26201    .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;}
26202    .btn-secondary:hover{background:var(--line);}
26203    .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;}
26204    .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;}
26205    .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;}
26206    @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));}}
26207    .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;}
26208    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26209    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26210    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26211    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26212    .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;}
26213    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26214    body.dark-theme .relocate-input{background:var(--surface-2);}
26215  </style>
26216</head>
26217<body>
26218  <div class="background-watermarks" aria-hidden="true">
26219    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26220    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26221    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26222    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26223    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26224    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26225  </div>
26226  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26227  <div class="top-nav">
26228    <div class="top-nav-inner">
26229      <a class="brand" href="/">
26230        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26231        <div class="brand-copy">
26232          <div class="brand-title">OxideSLOC</div>
26233          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26234        </div>
26235      </a>
26236      <div class="nav-right">
26237        <a class="nav-pill" href="/">Home</a>
26238        <div class="nav-dropdown">
26239          <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>
26240          <div class="nav-dropdown-menu">
26241            <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>
26242          </div>
26243        </div>
26244        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26245        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26246        <div class="nav-dropdown">
26247          <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>
26248          <div class="nav-dropdown-menu">
26249            <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>
26250          </div>
26251        </div>
26252        <div class="server-status-wrap" id="server-status-wrap">
26253          <div class="nav-pill server-online-pill" id="server-status-pill">
26254            <span class="status-dot" id="status-dot"></span>
26255            <span id="server-status-label">Server</span>
26256            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26257          </div>
26258          <div class="server-status-tip">
26259            OxideSLOC is running — accessible on your network.
26260            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26261          </div>
26262        </div>
26263        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26264          <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>
26265        </button>
26266        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26267          <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>
26268          <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>
26269        </button>
26270      </div>
26271    </div>
26272  </div>
26273
26274  <div class="page">
26275    <div class="panel">
26276      <h1>Scan Files Moved</h1>
26277      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26278      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26279      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26280      <div class="relocate-section">
26281        <h2>Locate Scan Output</h2>
26282        <p>Select the <strong>top-level</strong> scan output folder (the one named <code>project_YYYYMMDD-HHMM-&hellip;</code>). Result files will be found inside it automatically &mdash; do not navigate into a subfolder.</p>
26283        <div class="relocate-row">
26284          <input type="text" id="relocate-folder" name="folder_path"
26285                 value="{{ folder_hint }}"
26286                 placeholder="Path to folder containing scan output..."
26287                 class="relocate-input" autocomplete="off" spellcheck="false">
26288          {% if !server_mode %}
26289          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
26290          {% endif %}
26291        </div>
26292        <div style="margin-top:12px;">
26293          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26294        </div>
26295      </div>
26296      <div class="actions">
26297        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26298        <a class="btn-secondary" href="/view-reports">View Reports</a>
26299      </div>
26300    </div>
26301  </div>
26302  <footer class="site-footer">
26303    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
26304    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26305    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26306    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26307    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26308  </footer>
26309  <script nonce="{{ csp_nonce }}">
26310    (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");});})();
26311    (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);}})();
26312    (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;});})();
26313  </script>
26314  <script nonce="{{ csp_nonce }}">
26315  (function(){
26316    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'}];
26317    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);});}
26318    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26319    function init(){
26320      var btn=document.getElementById('settings-btn');if(!btn)return;
26321      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26322      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>';
26323      document.body.appendChild(m);
26324      var g=document.getElementById('scheme-grid');
26325      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);});
26326      var cl=document.getElementById('settings-close');
26327      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);
26328      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');});
26329      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26330      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26331    }
26332    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26333  }());
26334  (function(){
26335    var browseBtn=document.getElementById('browse-relocate-btn');
26336    if(browseBtn){
26337      browseBtn.addEventListener('click',function(){
26338        browseBtn.disabled=true;browseBtn.textContent='...';
26339        var inp=document.getElementById('relocate-folder');
26340        var hint=inp?inp.value:'';
26341        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
26342          .then(function(r){return r.ok?r.json():{cancelled:true};})
26343          .then(function(d){
26344            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26345            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26346          })
26347          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26348      });
26349    }
26350    var restoreBtn=document.getElementById('restore-btn');
26351    var errBox=document.getElementById('relocate-error-box');
26352    var okBox=document.getElementById('relocate-success-box');
26353    if(restoreBtn){
26354      restoreBtn.addEventListener('click',function(){
26355        var inp=document.getElementById('relocate-folder');
26356        var folder=inp?inp.value.trim():'';
26357        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26358        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26359        var body=new URLSearchParams();
26360        body.set('run_id','{{ run_id }}');
26361        body.set('redirect_url','{{ redirect_url }}');
26362        body.set('folder_path',folder);
26363        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26364          .then(function(r){return r.json();})
26365          .then(function(d){
26366            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26367            if(d&&d.ok){
26368              if(errBox)errBox.classList.add('hidden');
26369              if(okBox){okBox.style.display='block';}
26370              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26371            } else {
26372              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26373            }
26374          })
26375          .catch(function(e){
26376            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26377            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26378          });
26379      });
26380    }
26381  }());
26382  </script>
26383  <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]';
26384  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
26385  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>
26386</body>
26387</html>
26388"##,
26389    ext = "html"
26390)]
26391struct RelocateScanTemplate {
26392    message: String,
26393    run_id: String,
26394    folder_hint: String,
26395    redirect_url: String,
26396    server_mode: bool,
26397    csp_nonce: String,
26398    version: &'static str,
26399}
26400
26401// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
26402
26403#[derive(Template)]
26404#[template(
26405    source = r##"
26406<!doctype html>
26407<html lang="en">
26408<head>
26409  <meta charset="utf-8">
26410  <meta name="viewport" content="width=device-width, initial-scale=1">
26411  <title>OxideSLOC | View Reports</title>
26412  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26413  <style nonce="{{ csp_nonce }}">
26414    :root {
26415      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26416      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26417      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26418      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26419      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26420    }
26421    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; }
26422    *{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;}
26423    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26424    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26425    .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);}
26426    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26427    .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));}
26428    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26429    .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;}
26430    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26431    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26432    @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; } }
26433    .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;}
26434    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26435    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26436    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26437    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26438    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26439    .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;}
26440    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26441    .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);}
26442    .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;}
26443    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26444    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26445    .settings-modal-body{padding:14px 16px 16px;}
26446    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26447    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26448    .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;}
26449    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26450    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26451    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26452    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26453    .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;}
26454    .tz-select:focus{border-color:var(--oxide);}
26455    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26456    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26457    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26458    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26459    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26460    .panel-meta{font-size:13px;color:var(--muted);}
26461    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26462    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26463    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26464    .per-page-label{font-size:13px;color:var(--muted);}
26465    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;}
26466    .filter-input{min-width:180px;cursor:text;}
26467    .table-wrap{width:100%;overflow-x:auto;}
26468    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26469    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;}
26470    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26471    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26472    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26473    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26474    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26475    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26476    tr:last-child td{border-bottom:none;}
26477    tr:hover td{background:var(--surface-2);}
26478    .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);}
26479    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
26480    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26481    .metric-num{font-weight:700;color:var(--text);}
26482    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26483    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26484    .git-commit-chip{cursor:help;}
26485    .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
26486    .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;}
26487    .btn:hover{background:var(--line);}
26488    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26489    .btn.primary:hover{opacity:.9;}
26490    .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;}
26491    .btn-back:hover{background:var(--line);}
26492    .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;}
26493    .export-btn:hover{background:var(--line);}
26494    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26495    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26496    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26497    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26498    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26499    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26500    .pagination-info{font-size:13px;color:var(--muted);}
26501    .pagination-btns{display:flex;gap:6px;}
26502    .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;}
26503    .pg-btn:hover:not(:disabled){background:var(--line);}
26504    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26505    .pg-btn:disabled{opacity:.35;cursor:default;}
26506    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26507    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26508    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
26509    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26510    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26511    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26512    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
26513    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26514    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26515    .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;}
26516    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26517    .site-footer a{color:var(--muted);}
26518    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26519    .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%;}
26520    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26521    .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;}
26522    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26523    .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;}
26524    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26525    .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;}
26526    .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;}
26527    .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;}
26528    @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));}}
26529    .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;}
26530    .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;}
26531    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26532    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26533    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26534    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26535    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26536    .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;}
26537    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26538    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26539    .watched-chip-rm:hover{color:var(--oxide);}
26540    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26541    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26542    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26543    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26544    .rpt-btn{min-width:58px;justify-content:center;}
26545    .flex-row{display:flex;align-items:center;gap:8px;}
26546    .report-cell{overflow:visible;white-space:normal;}
26547    #history-table col:nth-child(1){width:185px;}
26548    #history-table col:nth-child(2){width:220px;}
26549    #history-table col:nth-child(3){width:100px;}
26550    #history-table col:nth-child(4){width:72px;}
26551    #history-table col:nth-child(5){width:82px;}
26552    #history-table col:nth-child(6){width:82px;}
26553    #history-table col:nth-child(7){width:65px;}
26554    #history-table col:nth-child(8){width:90px;}
26555    #history-table col:nth-child(9){width:85px;}
26556    #history-table col:nth-child(10){width:115px;}
26557    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26558    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26559    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26560    .submod-details summary::-webkit-details-marker{display:none;}
26561.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26562    .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;}
26563    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26564    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26565  </style>
26566</head>
26567<body>
26568  <div class="background-watermarks" aria-hidden="true">
26569    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26570    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26571    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575  </div>
26576  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26577  <div class="top-nav">
26578    <div class="top-nav-inner">
26579      <a class="brand" href="/">
26580        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26581        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26582      </a>
26583      <div class="nav-right">
26584        <a class="nav-pill" href="/">Home</a>
26585        <div class="nav-dropdown">
26586          <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>
26587          <div class="nav-dropdown-menu">
26588            <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>
26589          </div>
26590        </div>
26591        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26592        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26593        <div class="nav-dropdown">
26594          <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>
26595          <div class="nav-dropdown-menu">
26596            <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>
26597          </div>
26598        </div>
26599        <div class="server-status-wrap" id="server-status-wrap">
26600          <div class="nav-pill server-online-pill" id="server-status-pill">
26601            <span class="status-dot" id="status-dot"></span>
26602            <span id="server-status-label">Server</span>
26603            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26604          </div>
26605          <div class="server-status-tip">
26606            OxideSLOC is running — accessible on your network.
26607            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26608          </div>
26609        </div>
26610        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26611          <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>
26612        </button>
26613        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26614          <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>
26615          <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>
26616        </button>
26617      </div>
26618    </div>
26619  </div>
26620
26621  <div class="page">
26622    {% if let Some(err) = browse_error %}
26623    <div class="toast-error">
26624      <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>
26625      {{ err }}
26626    </div>
26627    {% endif %}
26628    {% if linked_count > 0 %}
26629    <div class="toast-success">
26630      <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>
26631      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26632    </div>
26633    {% endif %}
26634    <div class="watched-bar">
26635      <div class="watched-bar-left">
26636        <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>
26637        <span class="watched-label">Watched Folders</span>
26638        <div class="watched-chips">
26639          {% if server_mode %}
26640          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26641          {% else %}
26642          {% for dir in watched_dirs %}
26643          <span class="watched-chip">
26644            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26645            <form method="POST" action="/watched-dirs/remove" style="display:contents">
26646              <input type="hidden" name="folder_path" value="{{ dir }}">
26647              <input type="hidden" name="redirect_to" value="/view-reports">
26648              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
26649            </form>
26650          </span>
26651          {% endfor %}
26652          {% if watched_dirs.is_empty() %}
26653          <span class="watched-none">No folders watched — click Choose to add one</span>
26654          {% endif %}
26655          {% endif %}
26656        </div>
26657      </div>
26658      {% if !server_mode %}
26659      <div class="watched-bar-right">
26660        <button type="button" class="btn" id="add-watched-btn">
26661          <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>
26662          Choose
26663        </button>
26664        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26665          <input type="hidden" name="redirect_to" value="/view-reports">
26666          <button type="submit" class="btn">&#8635; Refresh</button>
26667        </form>
26668      </div>
26669      {% endif %}
26670    </div>
26671    {% if total_scans > 0 %}
26672    <div class="summary-strip">
26673      <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>
26674      <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>
26675      <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>
26676      <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>
26677    </div>
26678    {% endif %}
26679
26680    <section class="panel">
26681      <div class="panel-header">
26682        <div>
26683          <h1>View Reports</h1>
26684          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26685          {% 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 %}
26686        </div>
26687        <div class="flex-row">
26688          <button type="button" class="export-btn" id="export-csv-btn">
26689            <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>
26690            Export CSV
26691          </button>
26692          <button type="button" class="export-btn" id="export-xls-btn">
26693            <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>
26694            Export Excel
26695          </button>
26696        </div>
26697      </div>
26698
26699      {% if entries.is_empty() %}
26700      <div class="empty-state">
26701        <strong>No reports with viewable HTML yet</strong>
26702        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.
26703      </div>
26704      {% else %}
26705      <div class="filter-row">
26706        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
26707        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26708        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
26709      </div>
26710      <div class="table-wrap">
26711        <table id="history-table">
26712          <colgroup>
26713            <col><col><col><col><col><col><col><col><col><col>
26714          </colgroup>
26715          <thead>
26716            <tr id="history-thead">
26717              <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>
26718              <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>
26719              <th>Run ID<div class="col-resize-handle"></div></th>
26720              <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>
26721              <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>
26722              <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>
26723              <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>
26724              <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>
26725              <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>
26726              <th>Report<div class="col-resize-handle"></div></th>
26727            </tr>
26728          </thead>
26729          <tbody id="history-tbody">
26730            {% for entry in entries %}
26731            <tr class="history-row" data-run="{{ entry.run_id }}"
26732                data-timestamp="{{ entry.timestamp }}"
26733                data-project="{{ entry.project_label }}"
26734                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26735                data-skipped="{{ entry.files_skipped }}"
26736                data-comments="{{ entry.comment_lines }}"
26737                data-blank="{{ entry.blank_lines }}"
26738                data-physical="{{ entry.total_physical_lines }}"
26739                data-functions="{{ entry.functions }}"
26740                data-classes="{{ entry.classes }}"
26741                data-variables="{{ entry.variables }}"
26742                data-imports="{{ entry.imports }}"
26743                data-tests="{{ entry.test_count }}"
26744                data-branch="{{ entry.git_branch }}"
26745                data-commit="{{ entry.git_commit }}"
26746                data-has-json="{{ entry.has_json }}"
26747                data-html-url="/runs/html/{{ entry.run_id }}">
26748              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26749              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26750              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26751              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary"><span class="skipped-pill">{{ entry.files_skipped|commas }} skipped</span></div></td>
26752              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26753              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26754              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26755              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
26756              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
26757              <td class="report-cell">
26758                <div class="actions-cell">
26759                  {% 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 %}
26760                  {% 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 %}
26761                </div>
26762                {% if !entry.submodule_links.is_empty() %}
26763                <details class="submod-details">
26764                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
26765                  <div class="submod-link-list">
26766                    {% for sub in entry.submodule_links %}
26767                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26768                    {% endfor %}
26769                  </div>
26770                </details>
26771                {% endif %}
26772              </td>
26773            </tr>
26774            {% endfor %}
26775          </tbody>
26776        </table>
26777      </div>
26778      <div class="pagination">
26779        <span class="pagination-info" id="pagination-info"></span>
26780        <div class="pagination-btns" id="pagination-btns"></div>
26781        <div class="flex-row">
26782          <span class="per-page-label">Show</span>
26783          <select class="per-page" id="per-page-sel">
26784            <option value="10">10 per page</option>
26785            <option value="25" selected>25 per page</option>
26786            <option value="50">50 per page</option>
26787            <option value="100">100 per page</option>
26788          </select>
26789          <span class="per-page-label" id="page-range-label"></span>
26790        </div>
26791      </div>
26792      {% endif %}
26793    </section>
26794  </div>
26795
26796  <footer class="site-footer">
26797    local code analysis - metrics, history and reports
26798    &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>
26799    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26800    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26801    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26802    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26803  </footer>
26804
26805  <script nonce="{{ csp_nonce }}">
26806    (function () {
26807      // ── Theme ──────────────────────────────────────────────────────────────
26808      var storageKey = 'oxide-sloc-theme';
26809      var body = document.body;
26810      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26811      var toggle = document.getElementById('theme-toggle');
26812      if (toggle) toggle.addEventListener('click', function () {
26813        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26814        body.classList.toggle('dark-theme', next === 'dark');
26815        try { localStorage.setItem(storageKey, next); } catch(e) {}
26816      });
26817
26818      // ── State ─────────────────────────────────────────────────────────────
26819      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26820      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26821      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26822
26823      // Aggregate stats from first (most recent) row
26824      if (allRows.length) {
26825        var first = allRows[0];
26826        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();}
26827        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>':'');}
26828        setChipVal('agg-code', first.dataset.code);
26829        setChipVal('agg-files', first.dataset.files);
26830        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26831        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26832        Array.prototype.forEach.call(document.querySelectorAll('#history-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
26833      }
26834
26835      // ── Branch filter population ──────────────────────────────────────────
26836      (function() {
26837        var branches = {};
26838        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26839        var sel = document.getElementById('branch-filter');
26840        if (sel) Object.keys(branches).sort().forEach(function(b) {
26841          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26842        });
26843      })();
26844
26845      // ── Filter ────────────────────────────────────────────────────────────
26846      function getFilteredRows() {
26847        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26848        var branch = ((document.getElementById('branch-filter') || {}).value || '');
26849        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26850          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26851          if (branch && (r.dataset.branch || '') !== branch) return false;
26852          return true;
26853        });
26854      }
26855
26856      // ── Pagination ────────────────────────────────────────────────────────
26857      function renderPage() {
26858        var filtered = getFilteredRows();
26859        var total = filtered.length;
26860        var totalPages = Math.max(1, Math.ceil(total / perPage));
26861        currentPage = Math.min(currentPage, totalPages);
26862        var start = (currentPage - 1) * perPage;
26863        var end = Math.min(start + perPage, total);
26864        var shown = {};
26865        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26866        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26867          r.style.display = shown[r.dataset.run] ? '' : 'none';
26868        });
26869        var rl = document.getElementById('page-range-label');
26870        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26871        var info = document.getElementById('pagination-info');
26872        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26873        var btns = document.getElementById('pagination-btns');
26874        if (!btns) return;
26875        btns.innerHTML = '';
26876        function makeBtn(lbl, pg, active, disabled) {
26877          var b = document.createElement('button');
26878          b.className = 'pg-btn' + (active ? ' active' : '');
26879          b.textContent = lbl; b.disabled = disabled;
26880          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26881          return b;
26882        }
26883        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26884        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26885        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26886        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26887      }
26888
26889      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26890      window.applyFilters = function() { currentPage = 1; renderPage(); };
26891
26892      // ── Sorting ───────────────────────────────────────────────────────────
26893      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26894      function doSort(col, type, order) {
26895        var tbody = document.getElementById('history-tbody');
26896        if (!tbody) return;
26897        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26898        rows.sort(function(a, b) {
26899          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26900          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26901          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26902          return va < vb ? 1 : va > vb ? -1 : 0;
26903        });
26904        rows.forEach(function(r) { tbody.appendChild(r); });
26905        currentPage = 1; renderPage();
26906      }
26907      sortHeaders.forEach(function(th) {
26908        th.addEventListener('click', function(e) {
26909          if (e.target.classList.contains('col-resize-handle')) return;
26910          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26911          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26912          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26913          th.classList.add('sort-' + sortOrder);
26914          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26915          doSort(col, type, sortOrder);
26916        });
26917      });
26918
26919      // ── Column resize ─────────────────────────────────────────────────────
26920      (function() {
26921        var table = document.getElementById('history-table');
26922        if (!table) return;
26923        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26924        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26925        ths.forEach(function(th, i) {
26926          var handle = th.querySelector('.col-resize-handle');
26927          if (!handle || !cols[i]) return;
26928          var startX, startW;
26929          handle.addEventListener('mousedown', function(e) {
26930            e.stopPropagation(); e.preventDefault();
26931            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26932            handle.classList.add('dragging');
26933            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26934            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26935            document.addEventListener('mousemove', onMove);
26936            document.addEventListener('mouseup', onUp);
26937          });
26938        });
26939      })();
26940
26941      // ── Full-commit hover tooltip ─────────────────────────────────────────
26942      // The commit chips live inside an overflow:auto table wrapper, which would
26943      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26944      // (escaping the scroll container) and follow the cursor. Event delegation
26945      // keeps it working after pagination/sorting re-renders the rows.
26946      (function() {
26947        var tip = document.createElement('div');
26948        tip.className = 'commit-tip';
26949        tip.setAttribute('role', 'tooltip');
26950        document.body.appendChild(tip);
26951        var shown = false;
26952        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26953        function place(e) {
26954          var pad = 14, r = tip.getBoundingClientRect();
26955          var x = e.clientX + pad, y = e.clientY + pad;
26956          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26957          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26958          tip.style.left = x + 'px'; tip.style.top = y + 'px';
26959        }
26960        function hide() { tip.style.display = 'none'; shown = false; }
26961        document.addEventListener('mouseover', function(e) {
26962          var chip = chipFrom(e.target);
26963          if (!chip) return;
26964          var full = chip.getAttribute('data-full-commit');
26965          if (!full) return;
26966          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26967        });
26968        document.addEventListener('mousemove', function(e) {
26969          if (!shown) return;
26970          if (chipFrom(e.target)) place(e); else hide();
26971        });
26972        document.addEventListener('mouseout', function(e) {
26973          if (chipFrom(e.target)) hide();
26974        });
26975      })();
26976
26977      // ── Reset view ────────────────────────────────────────────────────────
26978      window.resetView = function() {
26979        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26980        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26981        sortCol = null; sortOrder = 'asc';
26982        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26983        var tbody = document.getElementById('history-tbody');
26984        if (tbody) {
26985          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26986          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26987          rows.forEach(function(r) { tbody.appendChild(r); });
26988        }
26989        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26990        var table = document.getElementById('history-table');
26991        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26992        currentPage = 1; renderPage();
26993      };
26994
26995      renderPage();
26996
26997      // ── Export helpers ────────────────────────────────────────────────────
26998      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
26999      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27000      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);}
27001      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;');}
27002      function slocXlsx(fname,sheet,hdrs,rows){
27003        var enc=new TextEncoder();
27004        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;}
27005        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;}
27006        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27007        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27008        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27009        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;}
27010        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27011        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];}
27012        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27013        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27014        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27015          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27016          +'<fonts count="2">'
27017            +'<font><sz val="11"/><name val="Calibri"/></font>'
27018            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27019          +'</fonts>'
27020          +'<fills count="3">'
27021            +'<fill><patternFill patternType="none"/></fill>'
27022            +'<fill><patternFill patternType="gray125"/></fill>'
27023            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27024          +'</fills>'
27025          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27026          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27027          +'<cellXfs count="4">'
27028            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27029            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27030            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27031            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27032          +'</cellXfs>'
27033          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27034          +'</styleSheet>';
27035        var rx='<row r="1">';
27036        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27037        rx+='</row>';
27038        rows.forEach(function(row,ri){
27039          var rn=ri+2;rx+='<row r="'+rn+'">';
27040          row.forEach(function(cell,c){
27041            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27042            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27043            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27044            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27045            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27046            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27047          });
27048          rx+='</row>';
27049        });
27050        var lastCol=hdrs.length,lastRow=rows.length+1;
27051        var tableRef='A1:'+colNm(lastCol)+lastRow;
27052        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27053          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27054          +'<autoFilter ref="'+tableRef+'"/>'
27055          +'<tableColumns count="'+lastCol+'">'
27056          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27057          +'</tableColumns>'
27058          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27059          +'</table>';
27060        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27061          +'<Relationships xmlns="'+pns+'relationships">'
27062          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27063          +'</Relationships>';
27064        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>';
27065        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27066          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27067          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27068          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27069          +'</worksheet>';
27070        var F={
27071          '[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/></Types>',
27072          '_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>',
27073          '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>',
27074          '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>',
27075          'xl/styles.xml':stl,
27076          'xl/sharedStrings.xml':ssXml,
27077          'xl/worksheets/sheet1.xml':sh,
27078          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27079          'xl/tables/table1.xml':tableXml
27080        };
27081        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/_rels/sheet1.xml.rels','xl/tables/table1.xml'];
27082        var zparts=[],zcds=[],zoff=0,znf=0;
27083        order.forEach(function(name){
27084          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27085          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]);
27086          var entry=new Uint8Array(lha.length+nb.length+sz);
27087          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27088          zparts.push(entry);
27089          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));
27090          var cde=new Uint8Array(cda.length+nb.length);
27091          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27092          zcds.push(cde);zoff+=entry.length;znf++;
27093        });
27094        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27095        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]);
27096        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27097        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27098        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27099        zout.set(new Uint8Array(ea),zpos);
27100        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27101      }
27102
27103      // Multi-sheet XLSX builder for the scan-history export.
27104      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27105      function slocXlsxMulti(fname,sheets){
27106        var enc=new TextEncoder();
27107        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;}
27108        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;}
27109        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27110        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27111        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27112        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];}
27113        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;}
27114        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27115        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27116        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27117          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27118          +'<fonts count="3">'
27119            +'<font><sz val="11"/><name val="Calibri"/></font>'
27120            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27121            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27122          +'</fonts>'
27123          +'<fills count="4">'
27124            +'<fill><patternFill patternType="none"/></fill>'
27125            +'<fill><patternFill patternType="gray125"/></fill>'
27126            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27127            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27128          +'</fills>'
27129          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27130          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27131          +'<cellXfs count="7">'
27132            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27133            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27134            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27135            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27136            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27137            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27138            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27139          +'</cellXfs>'
27140          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27141          +'</styleSheet>';
27142        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27143        sheets.forEach(function(sh,sheetIdx){
27144          var rx='<row r="1">';
27145          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27146          rx+='</row>';
27147          var rn=2;
27148          sh.rows.forEach(function(row){
27149            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27150            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27151              rx+='<row r="'+rn+'">';
27152              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27153              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27154              rx+='</row>';rn++;return;
27155            }
27156            rx+='<row r="'+rn+'">';
27157            row.forEach(function(cell,c){
27158              var ref=colRef(c,rn);
27159              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27160              if(typeof cell==='object'&&cell!==null){
27161                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27162                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27163                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27164                return;
27165              }
27166              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27167              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27168            });
27169            rx+='</row>';rn++;
27170          });
27171          var cw='';
27172          if(sh.colWidths&&sh.colWidths.length>0){
27173            cw='<cols>';
27174            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27175            cw+='</cols>';
27176          }
27177          var tblParts='';
27178          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27179            tableCounter++;
27180            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27181            var tRef='A1:'+colNm(colCount)+rowCount;
27182            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27183              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27184              +'<autoFilter ref="'+tRef+'"/>'
27185              +'<tableColumns count="'+colCount+'">'
27186              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27187              +'</tableColumns>'
27188              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27189              +'</table>';
27190            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27191              +'<Relationships xmlns="'+pns+'relationships">'
27192              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27193              +'</Relationships>';
27194            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27195          }
27196          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27197            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27198            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27199        });
27200        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>';
27201        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27202        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27203        var ctXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOver+ctTable+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>';
27204        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27205        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27206        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27207        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27208          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27209        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27210        var F={'[Content_Types].xml':ctXml,'_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
27211        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27212        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27213        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27214        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27215        var zparts=[],zcds=[],zoff=0,znf=0;
27216        order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});
27217        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27218        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]);
27219        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27220        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27221        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27222        zout.set(new Uint8Array(ea),zpos);
27223        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27224      }
27225
27226      var LANG_NAMES={'c':'C','cpp':'C++','c_sharp':'C#','go':'Go','java':'Java','java_script':'JavaScript','python':'Python','rust':'Rust','shell':'Shell','power_shell':'PowerShell','type_script':'TypeScript','assembly':'Assembly','clojure':'Clojure','css':'CSS','dart':'Dart','dockerfile':'Dockerfile','elixir':'Elixir','erlang':'Erlang','f_sharp':'F#','groovy':'Groovy','haskell':'Haskell','html':'HTML','julia':'Julia','kotlin':'Kotlin','lua':'Lua','makefile':'Makefile','nim':'Nim','objective_c':'Objective-C','ocaml':'OCaml','perl':'Perl','php':'PHP','r':'R','ruby':'Ruby','scala':'Scala','scss':'SCSS','sql':'SQL','svelte':'Svelte','swift':'Swift','vue':'Vue','xml':'XML','zig':'Zig','solidity':'Solidity','protobuf':'Protocol Buffers','hcl':'HCL/Terraform','graph_ql':'GraphQL','ada':'Ada','vhdl':'VHDL','verilog':'Verilog/SystemVerilog','tcl':'Tcl','pascal':'Pascal/Delphi','visual_basic':'Visual Basic','lisp':'Lisp/Scheme','fortran':'Fortran','nix':'Nix','crystal':'Crystal','d':'D','glsl':'GLSL/HLSL','cmake':'CMake','elm':'Elm','awk':'Awk'};
27227      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27228
27229      var _hh = ['Timestamp','Project','Run ID','Physical Lines','Code Lines','Comments','Blank Lines','Files Analyzed','Files Skipped','Functions','Classes','Variables','Imports','Tests','Code Density','Branch','Commit'];
27230      function getHistoryRows(){
27231        var r=[];
27232        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27233          var code=Number(tr.getAttribute('data-code'))||0;
27234          var phys=Number(tr.getAttribute('data-physical'))||0;
27235          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27236          r.push([
27237            tr.getAttribute('data-timestamp')||'',
27238            tr.getAttribute('data-project')||'',
27239            tr.getAttribute('data-run')||'',
27240            tr.getAttribute('data-physical')||'',
27241            tr.getAttribute('data-code')||'',
27242            tr.getAttribute('data-comments')||'',
27243            tr.getAttribute('data-blank')||'',
27244            tr.getAttribute('data-files')||'',
27245            tr.getAttribute('data-skipped')||'',
27246            tr.getAttribute('data-functions')||'',
27247            tr.getAttribute('data-classes')||'',
27248            tr.getAttribute('data-variables')||'',
27249            tr.getAttribute('data-imports')||'',
27250            tr.getAttribute('data-tests')||'',
27251            dens,
27252            tr.getAttribute('data-branch')||'',
27253            tr.getAttribute('data-commit')||''
27254          ]);
27255        });
27256        return r;
27257      }
27258      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27259      window.exportHistoryXls = function(){
27260        var histRows=getHistoryRows();
27261        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27262        var xlsxRows=histRows.map(function(r){return[r[0],r[1],r[2],toN(r[3]),toN(r[4]),toN(r[5]),toN(r[6]),toN(r[7]),toN(r[8]),toN(r[9]),toN(r[10]),toN(r[11]),toN(r[12]),toN(r[13]),{v:r[14],s:6},r[15],r[16]];});
27263        var histSheet={name:'Scan History',hdrs:_hh,rows:xlsxRows,colWidths:[18,14,22,14,12,12,12,12,12,11,10,10,10,8,13,10,12]};
27264        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27265        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27266        var runId=jsonRow.getAttribute('data-run')||'';
27267        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27268        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27269        fetch('/runs/json/'+runId)
27270          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27271          .then(function(run){
27272            var tot=run.summary_totals||{};
27273            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27274            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27275            function B(v){return{v:v,s:4};}
27276            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27277            var sumRows=[
27278              [{_sec:true,v:'RUN INFORMATION'}],
27279              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27280              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27281              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27282              [B('Branch'),run.git_branch||''],
27283              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27284              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27285              [B('Files Analyzed'),N(tot.files_analyzed)],
27286              [B('Files Skipped'),N(tot.files_skipped)],
27287              [],
27288              [{_sec:true,v:'CODE METRICS'}],
27289              [B('Physical Lines'),N(phys)],
27290              [B('Code Lines'),N(code)],
27291              [B('Comments'),N(tot.comment_lines)],
27292              [B('Blank Lines'),N(tot.blank_lines)],
27293              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27294              [B('Functions'),N(tot.functions)],
27295              [B('Classes / Types'),N(tot.classes)],
27296              [B('Variables'),N(tot.variables)],
27297              [B('Imports'),N(tot.imports)],
27298              [B('Tests'),N(tot.test_count)],
27299              [B('Assertions'),N(tot.test_assertion_count)],
27300              [B('Test Suites'),N(tot.test_suite_count)],
27301              [B('Code Density'),{v:dens,s:6}],
27302              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27303            ];
27304            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27305            var langRows=(run.totals_by_language||[]).map(function(l){
27306              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27307              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27308              return [langName(l.language),l.files||0,lp,lc,{v:ld,s:6},l.comment_lines||0,l.blank_lines||0,l.functions||0,l.classes||0,l.variables||0,l.imports||0,l.test_count||0,l.test_assertion_count||0,l.test_suite_count||0];
27309            });
27310            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27311            var pfRows=(run.per_file_records||[]).map(function(r){
27312              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27313              return [r.relative_path,langName(r.language),rc.total_physical_lines||0,ec.code_lines||0,ec.comment_lines||0,ec.blank_lines||0,rc.functions||0,rc.classes||0,rc.variables||0,rc.imports||0,rc.test_count||0,rc.test_assertion_count||0,r.size_bytes||0];
27314            });
27315            var skHdrs=['File','Status','Size (bytes)'];
27316            var skRows=(run.skipped_file_records||[]).map(function(r){
27317              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27318            });
27319            slocXlsxMulti('scan-history.xlsx',[
27320              histSheet,
27321              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27322              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27323              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27324              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27325            ]);
27326          })
27327          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27328      };
27329
27330      var csvBtn = document.getElementById('export-csv-btn');
27331      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27332      var xlsBtn = document.getElementById('export-xls-btn');
27333      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27334
27335      // ── Remaining CSP-safe event bindings ────────────────────────────────
27336      (function wireEvents() {
27337        var el;
27338        el = document.getElementById('reset-view-btn');
27339        if (el) el.addEventListener('click', window.resetView);
27340        el = document.getElementById('project-filter');
27341        if (el) el.addEventListener('input', window.applyFilters);
27342        el = document.getElementById('branch-filter');
27343        if (el) el.addEventListener('change', window.applyFilters);
27344        el = document.getElementById('per-page-sel');
27345        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27346        el = document.getElementById('add-watched-btn');
27347        if (el) el.addEventListener('click', function() {
27348          fetch('/pick-directory?kind=reports')
27349            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27350            .then(function(data) {
27351              if (!data.cancelled && data.selected_path) {
27352                var form = document.createElement('form');
27353                form.method = 'POST';
27354                form.action = '/watched-dirs/add';
27355                var ri = document.createElement('input');
27356                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27357                var fi = document.createElement('input');
27358                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27359                form.appendChild(ri); form.appendChild(fi);
27360                document.body.appendChild(form);
27361                form.submit();
27362              }
27363            })
27364            .catch(function(e) { alert('Could not open folder picker: ' + e); });
27365        });
27366      })();
27367
27368      (function randomizeWatermarks() {
27369        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27370        if (!wms.length) return;
27371        var placed = [];
27372        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;}
27373        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];}
27374        var half=Math.floor(wms.length/2);
27375        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;});
27376      })();
27377
27378      (function spawnCodeParticles() {
27379        var container = document.getElementById('code-particles');
27380        if (!container) return;
27381        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'];
27382        for (var i = 0; i < 38; i++) {
27383          (function(idx) {
27384            var el = document.createElement('span');
27385            el.className = 'code-particle';
27386            el.textContent = snippets[idx % snippets.length];
27387            var left = Math.random() * 94 + 2;
27388            var top = Math.random() * 88 + 6;
27389            var dur = (Math.random() * 10 + 9).toFixed(1);
27390            var delay = (Math.random() * 18).toFixed(1);
27391            var rot = (Math.random() * 26 - 13).toFixed(1);
27392            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27393            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';
27394            container.appendChild(el);
27395          })(i);
27396        }
27397      })();
27398    })();
27399  </script>
27400  <script nonce="{{ csp_nonce }}">
27401  (function(){
27402    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'}];
27403    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);});}
27404    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27405    function init(){
27406      var btn=document.getElementById('settings-btn');if(!btn)return;
27407      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27408      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>';
27409      document.body.appendChild(m);
27410      var g=document.getElementById('scheme-grid');
27411      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);});
27412      var cl=document.getElementById('settings-close');
27413      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);
27414      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');});
27415      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27416      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27417    }
27418    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27419  }());
27420  </script>
27421  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
27422</body>
27423</html>
27424"##,
27425    ext = "html"
27426)]
27427struct HistoryTemplate {
27428    version: &'static str,
27429    entries: Vec<HistoryEntryRow>,
27430    total_scans: usize,
27431    linked_count: usize,
27432    browse_error: Option<String>,
27433    watched_dirs: Vec<String>,
27434    csp_nonce: String,
27435    server_mode: bool,
27436}
27437
27438// ── CompareSelectTemplate ──────────────────────────────────────────────────────
27439
27440#[derive(Template)]
27441#[template(
27442    source = r##"
27443<!doctype html>
27444<html lang="en">
27445<head>
27446  <meta charset="utf-8">
27447  <meta name="viewport" content="width=device-width, initial-scale=1">
27448  <title>OxideSLOC | Compare Scans</title>
27449  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27450  <style nonce="{{ csp_nonce }}">
27451    :root {
27452      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27453      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27454      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27455      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27456      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27457    }
27458    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27459    *{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;}
27460    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27461    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27462    .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);}
27463    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27464    .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));}
27465    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27466    .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;}
27467    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27468    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27469    @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; } }
27470    .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;}
27471    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27472    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27473    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27474    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27475    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27476    .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;}
27477    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27478    .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);}
27479    .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;}
27480    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27481    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27482    .settings-modal-body{padding:14px 16px 16px;}
27483    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27484    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27485    .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;}
27486    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27487    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27488    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27489    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27490    .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;}
27491    .tz-select:focus{border-color:var(--oxide);}
27492    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27493    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27494    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27495    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27496    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27497    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27498    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27499    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27500    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27501    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27502    .per-page-label{font-size:13px;color:var(--muted);}
27503    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;}
27504    .filter-input{min-width:180px;cursor:text;}
27505    .table-wrap{width:100%;overflow-x:auto;}
27506    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27507    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;}
27508    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27509    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27510    #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;}
27511    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27512    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27513    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27514    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27515    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27516    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27517    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27518    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27519    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27520    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27521    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27522    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27523    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27524    tr:last-child td{border-bottom:none;}
27525    tr.selected td{background:var(--sel-bg);}
27526    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27527    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27528    tr{cursor:pointer;}
27529    tr.row-locked{opacity:.35;cursor:not-allowed;}
27530    tr.row-locked td{pointer-events:none;}
27531    .compare-all-bar{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;margin:10px 0 14px;align-items:center;}
27532    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27533    .compare-all-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:7px;border:1px solid var(--accent-2);background:rgba(111,155,255,0.08);color:var(--accent-2);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
27534    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27535    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27536    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27537    .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);}
27538    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
27539    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27540    .metric-num{font-weight:700;color:var(--text);}
27541    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27542    .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
27543    .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;}
27544    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27545    .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;}
27546    .btn:hover{background:var(--line);}
27547    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27548    .btn.primary:hover{opacity:.9;}
27549    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27550    .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;}
27551    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27552    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27553    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27554    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27555    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27556    .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;}
27557    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27558    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27559    .watched-chip-rm:hover{color:var(--oxide);}
27560    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27561    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27562    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27563    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27564    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27565    .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;}
27566    .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;}
27567    .btn-back:hover{background:var(--line);}
27568    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27569    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27570    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27571    .pagination-info{font-size:13px;color:var(--muted);}
27572    .pagination-btns{display:flex;gap:6px;}
27573    .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;}
27574    .pg-btn:hover:not(:disabled){background:var(--line);}
27575    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27576    .pg-btn:disabled{opacity:.35;cursor:default;}
27577    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27578    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27579    .site-footer a{color:var(--muted);}
27580    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27581    .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;}
27582    .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;}
27583    .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;}
27584    @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));}}
27585    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27586    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27587    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
27588    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27589    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27590    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27591    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
27592    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27593    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27594    .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;}
27595    .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;}
27596    .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%;}
27597    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27598    .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;}
27599    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27600    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27601    .hidden{display:none!important;}
27602    .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%;}
27603    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27604    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27605    .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;}
27606    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27607    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27608    .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;}
27609    .scope-option:hover{background:var(--line);}
27610    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27611    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27612    .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;}
27613    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27614    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27615    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27616    .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;}
27617  </style>
27618</head>
27619<body>
27620  <div class="background-watermarks" aria-hidden="true">
27621    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27622    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27627  </div>
27628  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27629  <div class="top-nav">
27630    <div class="top-nav-inner">
27631      <a class="brand" href="/">
27632        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27633        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27634      </a>
27635      <div class="nav-right">
27636        <a class="nav-pill" href="/">Home</a>
27637        <div class="nav-dropdown">
27638          <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>
27639          <div class="nav-dropdown-menu">
27640            <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>
27641          </div>
27642        </div>
27643        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27644        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27645        <div class="nav-dropdown">
27646          <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>
27647          <div class="nav-dropdown-menu">
27648            <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>
27649          </div>
27650        </div>
27651        <div class="server-status-wrap" id="server-status-wrap">
27652          <div class="nav-pill server-online-pill" id="server-status-pill">
27653            <span class="status-dot" id="status-dot"></span>
27654            <span id="server-status-label">Server</span>
27655            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27656          </div>
27657          <div class="server-status-tip">
27658            OxideSLOC is running — accessible on your network.
27659            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27660          </div>
27661        </div>
27662        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27663          <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>
27664        </button>
27665        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27666          <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>
27667          <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>
27668        </button>
27669      </div>
27670    </div>
27671  </div>
27672
27673  <div class="page">
27674    <div class="watched-bar">
27675      <div class="watched-bar-left">
27676        <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>
27677        <span class="watched-label">Watched Folders</span>
27678        <div class="watched-chips">
27679          {% if server_mode %}
27680          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27681          {% else %}
27682          {% for dir in watched_dirs %}
27683          <span class="watched-chip">
27684            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27685            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27686              <input type="hidden" name="folder_path" value="{{ dir }}">
27687              <input type="hidden" name="redirect_to" value="/compare-scans">
27688              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27689            </form>
27690          </span>
27691          {% endfor %}
27692          {% if watched_dirs.is_empty() %}
27693          <span class="watched-none">No folders watched — click Choose to add one</span>
27694          {% endif %}
27695          {% endif %}
27696        </div>
27697      </div>
27698      {% if !server_mode %}
27699      <div class="watched-bar-right">
27700        <button type="button" class="btn" id="add-watched-btn">
27701          <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>
27702          Choose
27703        </button>
27704        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27705          <input type="hidden" name="redirect_to" value="/compare-scans">
27706          <button type="submit" class="btn">&#8635; Refresh</button>
27707        </form>
27708      </div>
27709      {% endif %}
27710    </div>
27711    {% if total_scans > 0 %}
27712    <div class="summary-strip">
27713      <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>
27714      <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>
27715      <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>
27716      <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>
27717    </div>
27718    {% endif %}
27719    <section class="panel">
27720      <div class="panel-header">
27721        <div>
27722          <h1>Compare Scans</h1>
27723          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27724        </div>
27725        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27726          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27727            <button class="btn primary" id="compare-btn" disabled>
27728              <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>
27729              Compare <span class="sel-count" id="sel-count">0</span> Selected
27730            </button>
27731          </div>
27732        </div>
27733      </div>
27734
27735      {% if entries.is_empty() %}
27736      <div class="empty-state">
27737        <strong>No scans yet</strong>
27738        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.
27739      </div>
27740      {% else %}
27741      <div class="filter-row">
27742        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27743        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27744        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27745      </div>
27746      <div class="scope-panel hidden" id="scope-panel">
27747        <div class="scope-panel-label">
27748          <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>
27749          Compare scope — choose what to include
27750        </div>
27751        <div class="scope-options" id="scope-options"></div>
27752      </div>
27753      {% if total_scans > 0 %}
27754      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27755        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27756          <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>
27757          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27758        </div>
27759      </div>
27760      {% endif %}
27761      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27762        <span class="compare-all-label">
27763          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline></svg>
27764          Quick Compare All
27765        </span>
27766      </div>
27767      <div class="table-wrap">
27768        <table id="compare-table">
27769          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27770          <thead>
27771            <tr id="compare-thead">
27772              <th><div class="col-resize-handle"></div></th>
27773              <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>
27774              <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>
27775              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27776              <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>
27777              <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>
27778              <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>
27779              <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>
27780              <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>
27781              <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>
27782              <th>Submodules<div class="col-resize-handle"></div></th>
27783            </tr>
27784          </thead>
27785          <tbody id="compare-tbody">
27786            {% for entry in entries %}
27787            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27788                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27789                data-project="{{ entry.project_label }}"
27790                data-files="{{ entry.files_analyzed }}"
27791                data-code="{{ entry.code_lines }}"
27792                data-comments="{{ entry.comment_lines }}"
27793                data-blank="{{ entry.blank_lines }}"
27794                data-branch="{{ entry.git_branch }}"
27795                data-commit="{{ entry.git_commit }}"
27796                data-submodules="{{ entry.submodule_names_csv }}">
27797              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27798              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27799              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27800              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27801              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27802              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27803              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27804              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27805              <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>
27806              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" style="cursor:help;" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
27807              <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>
27808            </tr>
27809            {% endfor %}
27810          </tbody>
27811        </table>
27812      </div>
27813      <div class="pagination">
27814        <span class="pagination-info" id="pagination-info"></span>
27815        <div class="pagination-btns" id="pagination-btns"></div>
27816        <div class="flex-row">
27817          <span class="per-page-label">Show</span>
27818          <select class="per-page" id="per-page-sel">
27819            <option value="10">10 per page</option>
27820            <option value="25" selected>25 per page</option>
27821            <option value="50">50 per page</option>
27822            <option value="100">100 per page</option>
27823          </select>
27824          <span class="per-page-label" id="page-range-label"></span>
27825        </div>
27826      </div>
27827      {% endif %}
27828    </section>
27829  </div>
27830
27831  <footer class="site-footer">
27832    local code analysis - metrics, history and reports
27833    &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>
27834    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27835    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27836    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27837    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27838  </footer>
27839
27840  <script nonce="{{ csp_nonce }}">
27841    (function () {
27842      // ── Theme ──────────────────────────────────────────────────────────────
27843      var storageKey = 'oxide-sloc-theme';
27844      var body = document.body;
27845      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27846      var toggle = document.getElementById('theme-toggle');
27847      if (toggle) toggle.addEventListener('click', function () {
27848        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27849        body.classList.toggle('dark-theme', next === 'dark');
27850        try { localStorage.setItem(storageKey, next); } catch(e) {}
27851      });
27852
27853      // ── State ─────────────────────────────────────────────────────────────
27854      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27855      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27856      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27857      window._allCompareRows = allRows;
27858
27859      // ── Stat chips ────────────────────────────────────────────────────────
27860      (function() {
27861        var projects = {}, latestTs = '', latestRow = null;
27862        allRows.forEach(function(r) {
27863          var p = r.dataset.project || ''; if (p) projects[p] = true;
27864          var ts = r.dataset.timestamp || '';
27865          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27866        });
27867        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();}
27868        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>':'');}
27869        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27870        if (latestRow) {
27871          setChipVal('agg-code', latestRow.dataset.code);
27872          setChipVal('agg-files', latestRow.dataset.files);
27873        }
27874        Array.prototype.forEach.call(document.querySelectorAll('#compare-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
27875      })();
27876
27877      // ── Branch filter population ──────────────────────────────────────────
27878      (function() {
27879        var branches = {};
27880        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27881        var sel = document.getElementById('branch-filter');
27882        if (sel) Object.keys(branches).sort().forEach(function(b) {
27883          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27884        });
27885      })();
27886
27887      // ── Filter ────────────────────────────────────────────────────────────
27888      function getFilteredRows() {
27889        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27890        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27891        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27892          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27893          if (branch && (r.dataset.branch || '') !== branch) return false;
27894          return true;
27895        });
27896      }
27897
27898      // ── Pagination ────────────────────────────────────────────────────────
27899      function renderPage() {
27900        var filtered = getFilteredRows();
27901        var total = filtered.length;
27902        var totalPages = Math.max(1, Math.ceil(total / perPage));
27903        currentPage = Math.min(currentPage, totalPages);
27904        var start = (currentPage - 1) * perPage;
27905        var end = Math.min(start + perPage, total);
27906        var shown = {};
27907        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27908        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27909          r.style.display = shown[r.dataset.run] ? '' : 'none';
27910        });
27911        var rl = document.getElementById('page-range-label');
27912        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27913        var info = document.getElementById('pagination-info');
27914        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27915        var btns = document.getElementById('pagination-btns');
27916        if (!btns) return;
27917        btns.innerHTML = '';
27918        function makeBtn(lbl, pg, active, disabled) {
27919          var b = document.createElement('button');
27920          b.className = 'pg-btn' + (active ? ' active' : '');
27921          b.textContent = lbl; b.disabled = disabled;
27922          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27923          return b;
27924        }
27925        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27926        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27927        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27928        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27929      }
27930
27931      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27932      window.applyFilters = function() { currentPage = 1; renderPage(); };
27933
27934      // ── Sorting ───────────────────────────────────────────────────────────
27935      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27936      function doSort(col, type, order) {
27937        var tbody = document.getElementById('compare-tbody');
27938        if (!tbody) return;
27939        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27940        rows.sort(function(a, b) {
27941          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27942          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27943          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27944          return va < vb ? 1 : va > vb ? -1 : 0;
27945        });
27946        rows.forEach(function(r) { tbody.appendChild(r); });
27947        currentPage = 1; renderPage();
27948      }
27949      sortHeaders.forEach(function(th) {
27950        th.addEventListener('click', function(e) {
27951          if (e.target.classList.contains('col-resize-handle')) return;
27952          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27953          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27954          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27955          th.classList.add('sort-' + sortOrder);
27956          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27957          doSort(col, type, sortOrder);
27958        });
27959      });
27960
27961      // Apply default sort (timestamp desc) on initial load
27962      (function() {
27963        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27964        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27965      })();
27966
27967      // ── Column resize ─────────────────────────────────────────────────────
27968      (function() {
27969        var table = document.getElementById('compare-table');
27970        if (!table) return;
27971        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27972        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27973        ths.forEach(function(th, i) {
27974          var handle = th.querySelector('.col-resize-handle');
27975          if (!handle || !cols[i]) return;
27976          var startX, startW;
27977          handle.addEventListener('mousedown', function(e) {
27978            e.stopPropagation(); e.preventDefault();
27979            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27980            handle.classList.add('dragging');
27981            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27982            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27983            document.addEventListener('mousemove', onMove);
27984            document.addEventListener('mouseup', onUp);
27985          });
27986        });
27987      })();
27988
27989      // ── Full-commit hover tooltip ─────────────────────────────────────────
27990      // The commit chips live inside an overflow:auto table wrapper, which would
27991      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27992      // (escaping the scroll container) and follow the cursor. Event delegation
27993      // keeps it working after pagination/sorting re-renders the rows.
27994      (function() {
27995        var tip = document.createElement('div');
27996        tip.className = 'commit-tip';
27997        tip.setAttribute('role', 'tooltip');
27998        document.body.appendChild(tip);
27999        var shown = false;
28000        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28001        function place(e) {
28002          var pad = 14, r = tip.getBoundingClientRect();
28003          var x = e.clientX + pad, y = e.clientY + pad;
28004          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28005          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28006          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28007        }
28008        function hide() { tip.style.display = 'none'; shown = false; }
28009        document.addEventListener('mouseover', function(e) {
28010          var chip = chipFrom(e.target);
28011          if (!chip) return;
28012          var full = chip.getAttribute('data-full-commit');
28013          if (!full) return;
28014          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28015        });
28016        document.addEventListener('mousemove', function(e) {
28017          if (!shown) return;
28018          if (chipFrom(e.target)) place(e); else hide();
28019        });
28020        document.addEventListener('mouseout', function(e) {
28021          if (chipFrom(e.target)) hide();
28022        });
28023      })();
28024
28025      // ── Reset view ────────────────────────────────────────────────────────
28026      window.resetView = function() {
28027        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28028        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28029        sortCol = null; sortOrder = 'asc';
28030        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28031        var tbody = document.getElementById('compare-tbody');
28032        if (tbody) {
28033          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28034          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28035          rows.forEach(function(r) { tbody.appendChild(r); });
28036        }
28037        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28038        var table = document.getElementById('compare-table');
28039        currentPage = 1; renderPage();
28040        currentPage = 1; renderPage();
28041      };
28042
28043      renderPage();
28044      buildCompareAllBar();
28045
28046      // ── Row selection state ───────────────────────────────────────────────
28047      var selected = [];
28048      var lockedProject = null; // project label of first selected scan
28049
28050      function updateCompareBtn() {
28051        var btn = document.getElementById('compare-btn');
28052        var cnt = document.getElementById('sel-count');
28053        if (!btn) return;
28054        btn.disabled = selected.length < 2;
28055        if (cnt) cnt.textContent = selected.length;
28056      }
28057
28058      function applyProjectLock() {
28059        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28060        allRows.forEach(function(r) {
28061          if (lockedProject === null) {
28062            r.classList.remove('row-locked');
28063          } else {
28064            var proj = r.dataset.project || '';
28065            if (proj !== lockedProject) {
28066              r.classList.add('row-locked');
28067            } else {
28068              r.classList.remove('row-locked');
28069            }
28070          }
28071        });
28072      }
28073
28074      function toggleRow(row) {
28075        if (row.classList.contains('row-locked')) return;
28076        var vid = row.dataset.vid || row.dataset.run;
28077        var idx = selected.indexOf(vid);
28078        if (idx >= 0) {
28079          selected.splice(idx, 1);
28080          row.classList.remove('selected');
28081          var b = document.getElementById('badge-' + vid);
28082          if (b) b.textContent = '';
28083          // Release project lock if nothing selected
28084          if (selected.length === 0) lockedProject = null;
28085        } else {
28086          // Set project lock on first selection
28087          if (selected.length === 0) lockedProject = row.dataset.project || null;
28088          selected.push(vid);
28089          row.classList.add('selected');
28090        }
28091        selected.forEach(function(v, i) {
28092          var b = document.getElementById('badge-' + v);
28093          if (b) b.textContent = i + 1;
28094        });
28095        applyProjectLock();
28096        updateCompareBtn();
28097        buildScopePanel();
28098      }
28099
28100      // ── Compare-All bar ───────────────────────────────────────────────────
28101      function buildCompareAllBar() {
28102        var bar = document.getElementById('compare-all-bar');
28103        if (!bar) return;
28104        // Group all rows by project label.
28105        var groups = {};
28106        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28107        // Use all rows from the source data (not just visible).
28108        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28109        // We need ALL rows across all pages, not just the rendered ones.
28110        // Use the underlying allRows array that the pagination JS also uses.
28111        var sourceRows = window._allCompareRows || allRowsAll;
28112        sourceRows.forEach(function(r) {
28113          var proj = r.dataset.project || '';
28114          var vid = r.dataset.vid || r.dataset.run || '';
28115          if (!proj || !vid) return;
28116          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28117          groups[proj].ids.push(vid);
28118          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28119        });
28120        // Build buttons for each project with >= 2 scans.
28121        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28122        if (!keys.length) { bar.style.display = 'none'; return; }
28123        bar.style.display = 'flex';
28124        // Remove old buttons (keep label).
28125        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28126        oldBtns.forEach(function(b) { b.remove(); });
28127        keys.sort();
28128        keys.forEach(function(proj) {
28129          var g = groups[proj];
28130          var btn = document.createElement('button');
28131          btn.className = 'compare-all-btn';
28132          btn.type = 'button';
28133          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28134          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28135          btn.addEventListener('click', function() {
28136            // Sort ids by timestamp (ascending).
28137            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28138            pairs.sort(function(a, b) { return a.ts - b.ts; });
28139            var sorted = pairs.map(function(p) { return p.id; });
28140            if (sorted.length === 2) {
28141              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28142            } else {
28143              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28144            }
28145          });
28146          bar.appendChild(btn);
28147        });
28148      }
28149
28150      // ── Scope panel ───────────────────────────────────────────────────────
28151      var selectedScope = 'all';
28152
28153      function buildScopePanel() {
28154        var panel = document.getElementById('scope-panel');
28155        var opts = document.getElementById('scope-options');
28156        if (!panel || !opts) return;
28157        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28158
28159        // Collect union of submodules from all selected rows.
28160        var allSubs = {};
28161        selected.forEach(function(vid) {
28162          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28163          if (!row) return;
28164          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28165        });
28166        var subList = Object.keys(allSubs).sort();
28167        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28168
28169        panel.classList.remove('hidden');
28170        opts.innerHTML = '';
28171
28172        function makeOption(value, label, title) {
28173          var div = document.createElement('div');
28174          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28175          div.dataset.scopeValue = value;
28176          if (title) div.title = title;
28177          var radio = document.createElement('span');
28178          radio.className = 'scope-option-radio';
28179          var lbl = document.createElement('span');
28180          lbl.textContent = label;
28181          div.appendChild(radio);
28182          div.appendChild(lbl);
28183          div.addEventListener('click', function() {
28184            selectedScope = value;
28185            opts.querySelectorAll('.scope-option').forEach(function(o) {
28186              o.classList.toggle('selected', o.dataset.scopeValue === value);
28187            });
28188          });
28189          return div;
28190        }
28191
28192        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28193        var sep = document.createElement('span');
28194        sep.className = 'scope-option-sep';
28195        opts.appendChild(sep);
28196        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28197        subList.forEach(function(s) {
28198          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28199        });
28200      }
28201
28202      function doCompare() {
28203        if (selected.length < 2) return;
28204        if (selected.length === 2) {
28205          // Two-scan delta (existing flow with scope support).
28206          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28207          if (selectedScope === 'super') url += '&scope=super';
28208          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28209          window.location.href = url;
28210        } else {
28211          // Multi-scan timeline (N >= 3) — pass scope params too.
28212          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28213          if (selectedScope === 'super') url += '&scope=super';
28214          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28215          window.location.href = url;
28216        }
28217      }
28218
28219      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28220      var cbtn = document.getElementById('compare-btn');
28221      if (cbtn) cbtn.addEventListener('click', doCompare);
28222      var pfEl = document.getElementById('project-filter');
28223      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28224      var bfEl = document.getElementById('branch-filter');
28225      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28226      var rvBtn = document.getElementById('reset-view-btn');
28227      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28228      var ppSel = document.getElementById('per-page-sel');
28229      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28230
28231      var cmpTbody = document.getElementById('compare-tbody');
28232      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28233        var row = e.target.closest('.compare-row');
28234        if (row) toggleRow(row);
28235      });
28236
28237      (function randomizeWatermarks() {
28238        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28239        if (!wms.length) return;
28240        var placed = [];
28241        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;}
28242        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];}
28243        var half=Math.floor(wms.length/2);
28244        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;});
28245      })();
28246
28247      (function spawnCodeParticles() {
28248        var container = document.getElementById('code-particles');
28249        if (!container) return;
28250        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'];
28251        for (var i = 0; i < 38; i++) {
28252          (function(idx) {
28253            var el = document.createElement('span');
28254            el.className = 'code-particle';
28255            el.textContent = snippets[idx % snippets.length];
28256            var left = Math.random() * 94 + 2;
28257            var top = Math.random() * 88 + 6;
28258            var dur = (Math.random() * 10 + 9).toFixed(1);
28259            var delay = (Math.random() * 18).toFixed(1);
28260            var rot = (Math.random() * 26 - 13).toFixed(1);
28261            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28262            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';
28263            container.appendChild(el);
28264          })(i);
28265        }
28266      })();
28267
28268      // ── Watched folder picker ─────────────────────────────────────────────
28269      (function() {
28270        var btn = document.getElementById('add-watched-btn');
28271        if (!btn) return;
28272        btn.addEventListener('click', function() {
28273          fetch('/pick-directory?kind=reports')
28274            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28275            .then(function(data) {
28276              if (!data.cancelled && data.selected_path) {
28277                var form = document.createElement('form');
28278                form.method = 'POST';
28279                form.action = '/watched-dirs/add';
28280                var ri = document.createElement('input');
28281                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28282                var fi = document.createElement('input');
28283                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28284                form.appendChild(ri); form.appendChild(fi);
28285                document.body.appendChild(form);
28286                form.submit();
28287              }
28288            })
28289            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28290        });
28291      })();
28292
28293      // ── Submodule chip truncation ─────────────────────────────────────────
28294      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28295        var chips = cell.querySelectorAll('.submod-chip');
28296        var MAX = 4;
28297        if (chips.length <= MAX) return;
28298        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28299        var badge = document.createElement('span');
28300        badge.className = 'submod-overflow-badge';
28301        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28302        badge.textContent = '+' + (chips.length - MAX) + ' more';
28303        cell.appendChild(badge);
28304        cell.style.maxHeight = 'none';
28305      });
28306    })();
28307  </script>
28308  <script nonce="{{ csp_nonce }}">
28309  (function(){
28310    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'}];
28311    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);});}
28312    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28313    function init(){
28314      var btn=document.getElementById('settings-btn');if(!btn)return;
28315      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28316      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>';
28317      document.body.appendChild(m);
28318      var g=document.getElementById('scheme-grid');
28319      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);});
28320      var cl=document.getElementById('settings-close');
28321      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);
28322      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');});
28323      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28324      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28325    }
28326    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28327  }());
28328  </script>
28329  <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]';
28330  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
28331  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>
28332</body>
28333</html>
28334"##,
28335    ext = "html"
28336)]
28337struct CompareSelectTemplate {
28338    version: &'static str,
28339    entries: Vec<HistoryEntryRow>,
28340    total_scans: usize,
28341    watched_dirs: Vec<String>,
28342    csp_nonce: String,
28343    server_mode: bool,
28344}
28345
28346// ── CompareTemplate ────────────────────────────────────────────────────────────
28347
28348#[derive(Template)]
28349#[template(
28350    source = r##"
28351<!doctype html>
28352<html lang="en">
28353<head>
28354  <meta charset="utf-8">
28355  <meta name="viewport" content="width=device-width, initial-scale=1">
28356  <title>OxideSLOC | Scan Delta</title>
28357  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28358  <style nonce="{{ csp_nonce }}">
28359    :root {
28360      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28361      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28362      --nav:#283790; --nav-2:#013e6b;
28363      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28364      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28365      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28366    }
28367    body.dark-theme {
28368      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28369      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28370    }
28371    *{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;}
28372    .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);}
28373    .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;}
28374    .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));}
28375    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28376    .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;}
28377    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28378    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28379    @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; } }
28380    .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;}
28381    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28382    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28383    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28384    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28385    .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;}
28386    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28387    .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);}
28388    .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;}
28389    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28390    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28391    .settings-modal-body{padding:14px 16px 16px;}
28392    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28393    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28394    .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;}
28395    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28396    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28397    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28398    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28399    .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;}
28400    .tz-select:focus{border-color:var(--oxide);}
28401    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28402    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28403    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28404    .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;}
28405    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28406    .hero-body{display:block;}
28407    .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;}
28408    .btn-back:hover{background:var(--line);}
28409    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28410    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28411    .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;}
28412    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28413    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;}
28414    .muted{color:var(--muted);font-size:14px;}
28415    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28416    .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;}
28417    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28418    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28419    .vpill-arrow{font-size:20px;color:var(--muted);}
28420    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28421    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28422    .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;}
28423    .delta-card.delta-card-wide{padding:22px 24px;}
28424    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28425    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28426    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28427    .delta-card-from{font-size:15px;color:var(--muted);}
28428    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28429    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28430    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28431    .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%;}
28432    .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;}
28433    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28434    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28435    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28436    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28437    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28438    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28439    .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;}
28440    .meta-card-commit:hover{color:var(--oxide);}
28441    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28442    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28443    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28444    .meta-value{color:var(--text);font-size:13px;}
28445    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28446    .dc-tip{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:200;background:rgba(20,12,8,0.96);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:11.5px;font-weight:500;line-height:1.6;width:290px;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);text-transform:none;letter-spacing:0;}
28447    .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);}
28448    .delta-card:hover .dc-tip{display:block;}
28449    .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;}
28450    .export-btn:hover{background:var(--line);}
28451    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28452    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28453    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28454    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28455    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28456    .delta-card-change.zero{color:var(--muted);background:transparent;}
28457    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28458    .delta-card-pct.pos{color:var(--pos);}
28459    .delta-card-pct.neg{color:var(--neg);}
28460    .delta-card-pct.zero{color:var(--muted);}
28461    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28462    .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;}
28463    .insight-card.insight-flag{border-color:var(--oxide);}
28464    .insight-card:hover .dc-tip{display:block;}
28465    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28466    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28467    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28468    .insight-label.flag{color:var(--oxide);}
28469    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28470    .insight-val.pos{color:var(--pos);}
28471    .insight-val.neg{color:var(--neg);}
28472    .insight-val.high{color:#c0392a;}
28473    .insight-val.med{color:#926000;}
28474    .insight-val.low{color:var(--pos);}
28475    body.dark-theme .insight-val.high{color:#ff6b6b;}
28476    body.dark-theme .insight-val.med{color:#f0c060;}
28477    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28478    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28479    .fc-row{display:flex;align-items:center;gap:8px;}
28480    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28481    .fc-label{color:var(--muted);}
28482    .fc-modified .fc-count{color:#926000;}
28483    .fc-added .fc-count{color:var(--pos);}
28484    .fc-removed .fc-count{color:var(--neg);}
28485    .fc-unchanged .fc-count{color:var(--muted);}
28486    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28487    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28488    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28489    .chip.modified{background:#fff2d8;color:#926000;}
28490    .chip.added{background:#e8f5ed;color:#1a8f47;}
28491    .chip.removed{background:#fdeaea;color:#b33b3b;}
28492    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28493    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28494    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28495    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28496    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28497    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28498    .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;}
28499    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28500    .tab-btn:hover:not(.active){background:var(--line);}
28501    .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;}
28502    .btn-reset:hover{background:var(--line);}
28503    .table-wrap{width:100%;overflow-x:auto;}
28504    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28505    th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;background:var(--surface-2);}
28506    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28507    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28508    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28509    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28510    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28511    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28512    tr:last-child td{border-bottom:none;}
28513    tr:hover td{background:var(--surface-2);}
28514    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28515    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28516    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28517    /* Fixed layout: column widths come from the colgroup, not from scanning every
28518       row. With auto layout a large file matrix forces the browser to re-measure
28519       all cells on each reflow, which freezes the page during sort/resize. */
28520    #delta-table{table-layout:fixed;}
28521    #delta-table col:nth-child(1){width:32%;}
28522    #delta-table col:nth-child(2){width:11%;}
28523    #delta-table col:nth-child(3){width:11%;}
28524    #delta-table col:nth-child(4){width:16%;}
28525    #delta-table col:nth-child(5){width:10%;}
28526    #delta-table col:nth-child(6){width:10%;}
28527    #delta-table col:nth-child(7){width:10%;}
28528    tr.row-added td{background:rgba(26,143,71,0.04);}
28529    tr.row-removed td{background:rgba(179,59,59,0.06);}
28530    tr.row-modified td{background:rgba(146,96,0,0.04);}
28531    tr.row-unchanged td{color:var(--muted);}
28532    tr.row-unchanged .status-badge{opacity:.65;}
28533    .file-path{font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;display:inline-block;vertical-align:middle;}
28534    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28535    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28536    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28537    .status-badge.modified{background:#fff2d8;color:#926000;}
28538    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28539    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28540    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28541    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28542    .delta-val{font-weight:700;}
28543    .delta-val.pos{color:var(--pos);}
28544    .delta-val.neg{color:var(--neg);}
28545    .delta-val.zero{color:var(--muted);}
28546    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28547    .from-to strong{color:var(--text);font-weight:700;}
28548    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28549    .from-to .ft-absent{color:var(--muted);font-weight:600;}
28550    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28551    .site-footer a{color:var(--muted);}
28552    body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-reset,body.pdf-mode .filter-tabs,body.pdf-mode .filter-tabs-row,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .settings-modal,body.pdf-mode .site-footer,body.pdf-mode .scope-bar,body.pdf-mode .submod-scope-bar{display:none!important;}
28553    body.pdf-mode{background:#fff!important;}
28554    body.pdf-mode .page{padding:4px 6px 4px!important;}
28555    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28556    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28557    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28558    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28559    .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;}
28560    .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;}
28561    .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;}
28562    @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));}}
28563    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28564    .path-link:hover{color:var(--oxide-2);}
28565    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28566    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28567    a.vpill-id:hover{color:var(--oxide);}
28568    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28569    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28570    .pagination-info{font-size:13px;color:var(--muted);}
28571    .pagination-btns{display:flex;gap:6px;}
28572    .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;}
28573    .pg-btn:hover:not(:disabled){background:var(--line);}
28574    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28575    .pg-btn:disabled{opacity:.35;cursor:default;}
28576    .per-page-label{font-size:13px;color:var(--muted);}
28577    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;}
28578    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28579    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28580    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28581    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28582    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28583    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28584    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28585    .tab-btn.tab-unchanged{color:var(--muted);}
28586    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28587    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28588    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28589    .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;}
28590    .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;}
28591    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28592    .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;}
28593    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28594    .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;}
28595    .submod-scope-btn:hover{background:var(--line);}
28596    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28597    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28598    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28599    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28600    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28601    body.dark-theme .ic-card{background:var(--surface-2);}
28602    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28603    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28604    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28605    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28606    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28607    .ic-cb{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}.ic-cb:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
28608    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28609    .ic-card-h2-row .ic-card-h2{margin:0;}
28610    .ic-expand-btn{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;margin-left:auto;}
28611    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28612    .ic-svg-modal-ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
28613    .ic-svg-modal-ov.open{display:flex;}
28614    .ic-svg-modal{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:1100px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
28615    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28616    .ic-svg-modal-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}
28617    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28618    .ic-svg-modal-close{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}
28619    .ic-svg-modal-close:hover{background:var(--line);}
28620    .chart-metric-btn{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
28621    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28622    .chart-metric-btn:hover:not(.active){background:var(--line);}
28623    .chart-wrap{width:100%;overflow-x:auto;}
28624    #cmp-tl-svg{display:block;width:100%;}
28625    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
28626    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28627    #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;}
28628  </style>
28629</head>
28630<body>
28631  {{ loading_overlay|safe }}
28632  <div class="background-watermarks" aria-hidden="true">
28633    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28634    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28635    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28636    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28637    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28638    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28639  </div>
28640  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28641  <div class="top-nav">
28642    <div class="top-nav-inner">
28643      <a class="brand" href="/">
28644        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28645        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28646      </a>
28647      <div class="nav-right">
28648        <a class="nav-pill" href="/">Home</a>
28649        <div class="nav-dropdown">
28650          <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>
28651          <div class="nav-dropdown-menu">
28652            <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>
28653          </div>
28654        </div>
28655        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28656        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28657        <div class="nav-dropdown">
28658          <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>
28659          <div class="nav-dropdown-menu">
28660            <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>
28661          </div>
28662        </div>
28663        <div class="server-status-wrap" id="server-status-wrap">
28664          <div class="nav-pill server-online-pill" id="server-status-pill">
28665            <span class="status-dot" id="status-dot"></span>
28666            <span id="server-status-label">Server</span>
28667            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28668          </div>
28669          <div class="server-status-tip">
28670            OxideSLOC is running — accessible on your network.
28671            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28672          </div>
28673        </div>
28674        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28675          <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>
28676        </button>
28677        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28678          <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>
28679          <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>
28680        </button>
28681      </div>
28682    </div>
28683  </div>
28684
28685  <div class="page">
28686    <section class="hero">
28687      <div class="hero-header">
28688        <div>
28689          <h1 class="delta-title">Scan Delta</h1>
28690          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28691          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28692            {% if let Some(sub) = active_submodule %}
28693            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28694            {% else if super_scope_active %}
28695            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28696            {% else %}
28697            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28698            {% endif %}
28699            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28700          </div>
28701        </div>
28702        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28703          <a class="btn-back" href="/compare-scans">
28704            <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>
28705            Compare Scans
28706          </a>
28707          <div class="export-group" style="margin-top:12px;">
28708            <button type="button" class="export-btn" id="page-export-html-btn" title="Export page as HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
28709            <button type="button" class="export-btn" id="page-export-pdf-btn" title="Export page as PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
28710          </div>
28711        </div>
28712      </div>
28713      {% if has_any_submodule_data %}
28714      <div class="submod-scope-bar">
28715        <span class="submod-scope-label">
28716          <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>
28717          Scope:
28718        </span>
28719        <div class="submod-scope-divider"></div>
28720        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28721           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
28722           title="All files — super-repo and all submodules combined">Full scan</a>
28723        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28724           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
28725           title="Only files that are not part of any submodule">Super-repo only</a>
28726        {% for sub in submodule_options %}
28727        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28728           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
28729           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28730        {% endfor %}
28731      </div>
28732      {% endif %}
28733      <div class="hero-body">
28734      <div class="meta-strip">
28735        <div class="delta-card delta-card-meta">
28736          <div class="meta-card-header">
28737            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28738            <div class="meta-card-project-col">
28739              <div class="meta-card-project">{{ project_name }}</div>
28740              {% if has_any_submodule_data %}
28741              {% if let Some(sub) = active_submodule %}
28742              <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>
28743              {% else if super_scope_active %}
28744              <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>
28745              {% else %}
28746              <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>
28747              {% endif %}
28748              {% endif %}
28749            </div>
28750          </div>
28751          {% if !baseline_git_commit.is_empty() %}
28752          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28753          {% else %}
28754          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28755          {% endif %}
28756          <div class="meta-card-rows">
28757            <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>
28758            <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>
28759            <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>
28760            <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>
28761            {% if let Some(tags) = baseline_git_tags %}
28762            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28763            {% endif %}
28764          </div>
28765        </div>
28766        <div class="delta-card delta-card-meta">
28767          <div class="meta-card-header">
28768            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28769            <div class="meta-card-project-col">
28770              <div class="meta-card-project">{{ project_name }}</div>
28771              {% if has_any_submodule_data %}
28772              {% if let Some(sub) = active_submodule %}
28773              <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>
28774              {% else if super_scope_active %}
28775              <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>
28776              {% else %}
28777              <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>
28778              {% endif %}
28779              {% endif %}
28780            </div>
28781          </div>
28782          {% if !current_git_commit.is_empty() %}
28783          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28784          {% else %}
28785          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28786          {% endif %}
28787          <div class="meta-card-rows">
28788            <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>
28789            <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>
28790            <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>
28791            <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>
28792            {% if let Some(tags) = current_git_tags %}
28793            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28794            {% endif %}
28795          </div>
28796        </div>
28797      </div>
28798      <div class="delta-strip">
28799        <div class="delta-card">
28800          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28801          <div class="delta-card-label">Code lines</div>
28802          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28803          <div class="delta-card-to">{{ current_code_fmt }}</div>
28804          {% 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>
28805          {% 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>
28806          {% else %}<div class="delta-card-pct zero">±0%</div>
28807          {% endif %}
28808        </div>
28809        <div class="delta-card">
28810          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28811          <div class="delta-card-label">Files analyzed</div>
28812          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28813          <div class="delta-card-to">{{ current_files_fmt }}</div>
28814          {% 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>
28815          {% 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>
28816          {% else %}<div class="delta-card-pct zero">±0%</div>
28817          {% endif %}
28818        </div>
28819        <div class="delta-card">
28820          <div class="dc-tip">Comment-only lines per the active parser policy.<br>A rise indicates more docs; a drop may reflect comment cleanup.</div>
28821          <div class="delta-card-label">Comment lines</div>
28822          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28823          <div class="delta-card-to">{{ current_comments_fmt }}</div>
28824          {% 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>
28825          {% 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>
28826          {% else %}<div class="delta-card-pct zero">±0%</div>
28827          {% endif %}
28828        </div>
28829        {{ coverage_delta_card|safe }}
28830        <div class="delta-card delta-card-wide">
28831          <div class="dc-tip">Per-file breakdown.<br>Modified = at least one count changed.<br>Unchanged = identical counts in both scans.<br>Added/Removed = only in one scan.</div>
28832          <div class="delta-card-label">File changes</div>
28833          <div class="file-changes-grid">
28834            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28835            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28836            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28837            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28838          </div>
28839        </div>
28840      </div>
28841      <div class="insights-panel">
28842        <div class="insight-card">
28843          <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans.<br>Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
28844          <div class="insight-label">Lines Added</div>
28845          <div class="insight-val pos">+{{ code_lines_added }}</div>
28846          <div class="insight-sub">New or grown source lines</div>
28847        </div>
28848        <div class="insight-card">
28849          <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans.<br>Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
28850          <div class="insight-label">Lines Removed</div>
28851          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
28852          <div class="insight-sub">Deleted or shrunk source lines</div>
28853        </div>
28854        <div class="insight-card">
28855          <div class="dc-tip up">Measures total editing activity relative to codebase size.<br>Formula: (lines added + lines removed) &divide; baseline code lines &times; 100%.<br>Above 20% = high activity<br>5&ndash;20% = normal velocity<br>Below 5% = stable baseline.</div>
28856          <div class="insight-label">Churn Rate</div>
28857          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28858          <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>
28859        </div>
28860        {% if scope_flag %}
28861        <div class="insight-card insight-flag">
28862          <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new.<br>Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline.<br>This often signals a large feature branch, a bulk import, or a generated-file inclusion.<br>Review the file-level delta below to confirm scope.{% endif %}</div>
28863          <div class="insight-label flag">Scope Signal</div>
28864          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28865          <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>
28866        </div>
28867        {% endif %}
28868      </div>
28869      </div>
28870    </section>
28871
28872    <section class="panel" id="inline-charts-section">
28873      <div class="panel-title">Scan Delta Charts</div>
28874      <div class="ic-grid">
28875        <div class="ic-card" style="grid-column:span 2">
28876          <div class="ic-card-h2-row">
28877            <span class="ic-card-h2">Timeline</span>
28878            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28879              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28880              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28881              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28882              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28883              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28884            </div>
28885            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
28886          </div>
28887          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28888        </div>
28889        <div class="ic-card">
28890          <div class="ic-card-h2-row"><span class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</span><button class="ic-expand-btn" data-expand-src="ic-c1" data-expand-title="Code Metrics — Baseline vs Current">&#x2922; Full View</button></div>
28891          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#C45C10"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#2A6846"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#D4A017"></span><span style="color:#D4A017;font-weight:600">Comments</span></span></div>
28892          <div id="ic-c1"></div>
28893        </div>
28894        <div class="ic-card" id="ic-lang-card">
28895          <div class="ic-card-h2-row"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="ic-c3" data-expand-title="Language Code Delta">&#x2922; Full View</button></div>
28896          <div id="ic-c3"></div>
28897        </div>
28898        <div class="ic-card">
28899          <div class="ic-card-h2-row"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="ic-c2" data-expand-title="Delta by Metric">&#x2922; Full View</button></div>
28900          <div id="ic-c2"></div>
28901        </div>
28902        <div class="ic-card">
28903          <div class="ic-card-h2-row"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="ic-c4" data-expand-title="File Change Distribution">&#x2922; Full View</button></div>
28904          <div id="ic-c4"></div>
28905        </div>
28906      </div>
28907      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28908        <div class="ic-svg-modal">
28909          <div class="ic-svg-modal-hdr">
28910            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28911            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
28912          </div>
28913          <div id="ic-svg-modal-body"></div>
28914        </div>
28915      </div>
28916    </section>
28917
28918    <section class="panel">
28919      <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
28920      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28921        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28922          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28923          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28924          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28925          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28926          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28927        </div>
28928        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28929          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
28930          <div class="export-group">
28931            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
28932            <button type="button" class="export-btn" id="delta-csv-btn">
28933              <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>
28934              CSV
28935            </button>
28936            <button type="button" class="export-btn" id="delta-xls-btn">
28937              <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>
28938              Excel
28939            </button>
28940          </div>
28941        </div>
28942      </div>
28943
28944      <div class="table-wrap">
28945      <table id="delta-table">
28946        <colgroup>
28947          <col>
28948          <col>
28949          <col>
28950          <col>
28951          <col>
28952          <col>
28953          <col>
28954        </colgroup>
28955        <thead>
28956          <tr id="delta-thead">
28957            <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>
28958            <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>
28959            <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>
28960            <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>
28961            <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>
28962            <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>
28963            <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>
28964          </tr>
28965        </thead>
28966        <tbody id="delta-tbody">
28967          {% for row in file_rows %}
28968          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28969              data-path="{{ row.relative_path }}"
28970              data-language="{{ row.language }}"
28971              data-baseline-code="{{ row.baseline_code }}"
28972              data-current-code="{{ row.current_code }}"
28973              data-code-delta="{{ row.code_delta_str }}"
28974              data-comment-delta="{{ row.comment_delta_str }}"
28975              data-total-delta="{{ row.total_delta_str }}"
28976              data-orig-idx="">
28977            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28978            <td class="hide-sm">{{ row.language }}</td>
28979            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28980            <td><span class="from-to" data-baseline="{{ row.baseline_code }}" data-current="{{ row.current_code }}">{% if row.baseline_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.baseline_code_display }}</strong>{% endif %}<span class="ft-sep">→</span>{% if row.current_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.current_code_display }}</strong>{% endif %}</span></td>
28981            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28982            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28983            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28984          </tr>
28985          {% endfor %}
28986        </tbody>
28987      </table>
28988      </div>
28989      <div class="pagination">
28990        <span class="pagination-info" id="pg-range-label"></span>
28991        <div class="pagination-btns" id="pg-btns"></div>
28992        <div class="flex-row">
28993          <span class="per-page-label">Show</span>
28994          <select class="per-page" id="per-page-sel">
28995            <option value="10">10 per page</option>
28996            <option value="25" selected>25 per page</option>
28997            <option value="50">50 per page</option>
28998            <option value="100">100 per page</option>
28999          </select>
29000        </div>
29001      </div>
29002    </section>
29003  </div>
29004
29005  <div id="ic-tt"></div>
29006
29007  <footer class="site-footer">
29008    local code analysis - metrics, history and reports
29009    &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>
29010    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29011    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29012    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29013    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29014  </footer>
29015
29016  <script nonce="{{ csp_nonce }}">
29017    (function () {
29018      var storageKey = 'oxide-sloc-theme';
29019      var body = document.body;
29020      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29021      var toggle = document.getElementById('theme-toggle');
29022      if (toggle) toggle.addEventListener('click', function () {
29023        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29024        body.classList.toggle('dark-theme', next === 'dark');
29025        try { localStorage.setItem(storageKey, next); } catch(e) {}
29026      });
29027
29028      (function randomizeWatermarks() {
29029        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29030        if (!wms.length) return;
29031        var placed = [];
29032        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;}
29033        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];}
29034        var half=Math.floor(wms.length/2);
29035        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;});
29036      })();
29037
29038      (function spawnCodeParticles() {
29039        var container = document.getElementById('code-particles');
29040        if (!container) return;
29041        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'];
29042        for (var i = 0; i < 38; i++) {
29043          (function(idx) {
29044            var el = document.createElement('span');
29045            el.className = 'code-particle';
29046            el.textContent = snippets[idx % snippets.length];
29047            var left = Math.random() * 94 + 2;
29048            var top = Math.random() * 88 + 6;
29049            var dur = (Math.random() * 10 + 9).toFixed(1);
29050            var delay = (Math.random() * 18).toFixed(1);
29051            var rot = (Math.random() * 26 - 13).toFixed(1);
29052            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29053            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';
29054            container.appendChild(el);
29055          })(i);
29056        }
29057      })();
29058    })();
29059
29060    var activeStatusFilter = 'all';
29061    var deltaPerPage = 25, deltaCurrPage = 1;
29062
29063    function openFolder(path) {
29064      fetch('/open-path?path=' + encodeURIComponent(path))
29065        .then(function (r) { return r.json(); })
29066        .then(function (d) {
29067          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29068        })
29069        .catch(function () {});
29070    }
29071
29072    // \u2500\u2500 File-matrix model (windowed render) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
29073    // The server renders every row once; we lift them into a plain-data array and
29074    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29075    // filtering run on the array (no DOM churn) and each render rebuilds just one
29076    // page (~25 rows). This keeps every interaction O(page) instead of O(all
29077    // files): a 28k-row table previously re-touched every node on each click
29078    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29079    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29080
29081    function parseDeltaNum(str) {
29082      if (!str || str === '\u2014') return 0;
29083      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29084    }
29085
29086    function captureDelta() {
29087      var tbody = document.getElementById('delta-tbody');
29088      if (!tbody) return;
29089      var rows = tbody.querySelectorAll('.delta-row');
29090      for (var i = 0; i < rows.length; i++) {
29091        var r = rows[i];
29092        DELTA.push({
29093          h: r.innerHTML,
29094          cls: r.className,
29095          path: r.getAttribute('data-path') || '',
29096          lang: r.getAttribute('data-language') || '',
29097          status: r.getAttribute('data-status') || '',
29098          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29099          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29100          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29101          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29102          td: parseDeltaNum(r.getAttribute('data-total-delta')),
29103          bcs: r.getAttribute('data-baseline-code') || '',
29104          ccs: r.getAttribute('data-current-code') || '',
29105          cds: r.getAttribute('data-code-delta') || '',
29106          cmds: r.getAttribute('data-comment-delta') || '',
29107          tds: r.getAttribute('data-total-delta') || ''
29108        });
29109      }
29110      tbody.innerHTML = '';
29111    }
29112
29113    function applyDeltaQuery() {
29114      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29115        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29116      if (sortCol) {
29117        var asc = sortOrder === 'asc';
29118        v.sort(function(a, b) {
29119          var va, vb;
29120          if (sortCol === 'path') { va = a.path; vb = b.path; }
29121          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29122          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29123          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29124          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29125          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29126          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29127          else { return 0; }
29128          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29129          return va < vb ? 1 : va > vb ? -1 : 0;
29130        });
29131      }
29132      _deltaView = v;
29133      deltaCurrPage = 1;
29134      renderDeltaPage();
29135    }
29136
29137    function renderDeltaPage() {
29138      var total = _deltaView.length;
29139      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29140      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29141      if (deltaCurrPage < 1) deltaCurrPage = 1;
29142      var start = (deltaCurrPage - 1) * deltaPerPage;
29143      var end = Math.min(start + deltaPerPage, total);
29144      var tbody = document.getElementById('delta-tbody');
29145      if (tbody) {
29146        var html = '';
29147        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29148        tbody.innerHTML = html;
29149      }
29150      var rl = document.getElementById('pg-range-label');
29151      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29152      var btns = document.getElementById('pg-btns');
29153      if (!btns) return;
29154      btns.innerHTML = '';
29155      if (totalPages <= 1) return;
29156      function makeBtn(lbl, pg, active, disabled) {
29157        var b = document.createElement('button');
29158        b.className = 'pg-btn' + (active ? ' active' : '');
29159        b.textContent = lbl; b.disabled = disabled;
29160        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29161        return b;
29162      }
29163      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29164      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29165      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29166      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29167    }
29168
29169    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29170
29171    function filterRows(status, btn) {
29172      activeStatusFilter = status;
29173      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29174        b.classList.remove('active');
29175      });
29176      if (btn) btn.classList.add('active');
29177      applyDeltaQuery();
29178    }
29179
29180    // ── Sorting ──────────────────────────────────────────────────────────────
29181    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29182    sortHeaders.forEach(function(th) {
29183      th.addEventListener('click', function(e) {
29184        if (e.target.classList.contains('col-resize-handle')) return;
29185        var col = th.dataset.sortCol;
29186        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29187        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29188        th.classList.add('sort-' + sortOrder);
29189        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29190        applyDeltaQuery();
29191      });
29192    });
29193
29194    // ── Column resize ─────────────────────────────────────────────────────────
29195    (function() {
29196      var table = document.getElementById('delta-table');
29197      if (!table) return;
29198      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29199      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29200      ths.forEach(function(th, i) {
29201        var handle = th.querySelector('.col-resize-handle');
29202        if (!handle || !cols[i]) return;
29203        handle.addEventListener('mousedown', function(e) {
29204          e.stopPropagation(); e.preventDefault();
29205          // Lock every column to its current rendered px width and size the table
29206          // to the column total. With table-layout:fixed + width:100% the table is
29207          // pinned to the container, so widening one <col> only rebalances the rest
29208          // and the drag looks inert; pinning px widths lets the column actually
29209          // grow while the wrapper (overflow-x:auto) scrolls.
29210          var startTableW = 0;
29211          for (var k = 0; k < ths.length; k++) {
29212            if (!cols[k]) continue;
29213            var w = ths[k].getBoundingClientRect().width;
29214            cols[k].style.width = w + 'px';
29215            startTableW += w;
29216          }
29217          table.style.width = startTableW + 'px';
29218          var startX = e.clientX;
29219          var startW = ths[i].getBoundingClientRect().width;
29220          handle.classList.add('dragging');
29221          function onMove(ev) {
29222            var newW = Math.max(40, startW + ev.clientX - startX);
29223            cols[i].style.width = newW + 'px';
29224            table.style.width = (startTableW + (newW - startW)) + 'px';
29225          }
29226          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29227          document.addEventListener('mousemove', onMove);
29228          document.addEventListener('mouseup', onUp);
29229        });
29230      });
29231    })();
29232
29233    // ── Reset ─────────────────────────────────────────────────────────────────
29234    window.resetDeltaTable = function() {
29235      sortCol = null; sortOrder = 'asc';
29236      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29237      var table = document.getElementById('delta-table');
29238      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29239      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29240      activeStatusFilter = 'all';
29241      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29242      var allBtn = document.querySelector('.tab-btn');
29243      if (allBtn) allBtn.classList.add('active');
29244      applyDeltaQuery();
29245    };
29246
29247    // Compact number formatter (shared by the delta table; charts define their own locally)
29248    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();}
29249    function fmtFull(n){return Number(n).toLocaleString();}
29250
29251    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29252    function fmtFromTo() {
29253      var tbody = document.getElementById('delta-tbody');
29254      if (!tbody) return;
29255      tbody.querySelectorAll('.delta-row').forEach(function(row) {
29256        var status = row.dataset.status || '';
29257        var ft = row.querySelector('.from-to');
29258        if (!ft) return;
29259        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29260        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29261        var strongs = ft.querySelectorAll('strong');
29262        // Apply fmt() to non-absent strong values
29263        strongs.forEach(function(el) {
29264          var n = parseInt(el.textContent, 10);
29265          if (!isNaN(n)) el.textContent = fmtFull(n);
29266        });
29267        // Safety: force dash for genuinely absent sides
29268        if (status === 'added' && bv === 0) {
29269          var bs = ft.querySelector('strong:first-of-type');
29270          if (bs && bs.textContent === '0') {
29271            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29272          }
29273        }
29274        if (status === 'removed' && cv === 0) {
29275          var cs = ft.querySelector('strong:last-of-type');
29276          if (cs && cs.textContent === '0') {
29277            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29278          }
29279        }
29280      });
29281    }
29282    // Initialize: format the server-rendered rows, lift them into the data model
29283    // (which also clears the DOM), then render only the first page.
29284    fmtFromTo();
29285    captureDelta();
29286    applyDeltaQuery();
29287
29288    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29289    (function() {
29290      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29291        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29292      });
29293      var resetBtn = document.getElementById('delta-reset-btn');
29294      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29295      var csvBtn = document.getElementById('delta-csv-btn');
29296      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29297      var xlsBtn = document.getElementById('delta-xls-btn');
29298      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29299      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29300      function sdFetchUri(path) {
29301        return fetch(path).then(function(r){return r.blob();}).then(function(b){
29302          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29303        }).catch(function(){return '';});
29304      }
29305      function sdInlineImgs(html, cb) {
29306        var paths=[], seen={};
29307        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29308        if(!paths.length){cb(html);return;}
29309        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29310          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29311          .catch(function(){cb(html);});
29312      }
29313      function buildFullPageHtml(pdfMode) {
29314        if(pdfMode) document.body.classList.add('pdf-mode');
29315        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29316        renderDeltaPage();
29317        var html = document.documentElement.outerHTML;
29318        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29319        if(pdfMode) document.body.classList.remove('pdf-mode');
29320        return html;
29321      }
29322      var chartsBtn = document.getElementById('delta-charts-btn');
29323      if (chartsBtn) chartsBtn.addEventListener('click', function() {
29324        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29325        sdInlineImgs(buildFullPageHtml(false), function(html) {
29326          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29327          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29328          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29329          btn.disabled=false;btn.innerHTML=orig;
29330        });
29331      });
29332      var pageHtmlBtn = document.getElementById('page-export-html-btn');
29333      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29334        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29335        sdInlineImgs(buildFullPageHtml(false), function(html) {
29336          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29337          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29338          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29339          btn.disabled=false;btn.innerHTML=orig;
29340        });
29341      });
29342      // PDF export — clean document-style report, not a web page screenshot
29343      function buildDeltaPdfHtml() {
29344        var sd=_sd, dr=getDeltaExportRows();
29345        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29346        function pct(b,c){b=Number(b);c=Number(c);if(!b)return c>0?'new':'±0%';var v=(c-b)/b*100,t=v.toFixed(1);return(t==='0.0'||t==='-0.0')?'±0%':(v>0?'+':'')+t+'%';}
29347        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29348        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29349        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29350        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29351        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29352        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29353        function fmtN(n){return Number(n).toLocaleString();}
29354        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29355        function delt(v){var s=String(v==null?'\u2014':v);if(!s||s==='0'||s==='\u2014')return'<span>'+esc(s)+'</span>';return s.charAt(0)==='-'?'<span style="color:#b23030;font-weight:700">'+esc(s)+'</span>':'<span style="color:#2a6846;font-weight:700">'+esc(s)+'</span>';}
29356        var lm={};
29357        dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0,c=parseInt(r[4])||0;if(!lm[l])lm[l]={f:0,d:0,c:0};lm[l].f++;lm[l].d+=d;lm[l].c+=c;});
29358        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29359        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29360        // The header/footer flow in normal document order (NOT position:fixed).
29361        // A fixed header repeats on every printed page in Chromium and overlaps
29362        // the content beneath it — silently swallowing the first few table rows of
29363        // pages 2+ and clipping the summary cards on page 1. Letting the header
29364        // flow once at the top and relying on the table's <thead> (which Chromium
29365        // repeats per page) keeps every row visible. `.body` keeps a small inset
29366        // so nothing bleeds to the sheet edge.
29367        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29368          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29369          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29370          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29371          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29372          '.ph-brand em{color:#c45c10;font-style:normal;}'+
29373          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29374          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29375          '.info-bar{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29376          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29377          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29378          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29379          '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29380          '.body{padding:12px 18px 0;}'+
29381          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29382          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29383          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29384          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29385          '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:8px 12px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
29386          '.meta>div{flex:1 1 0;}'+
29387          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29388          '.sec{margin-bottom:10px;}'+
29389          '.sh{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29390          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29391          '.pg-rhdr-in{display:flex;justify-content:space-between;align-items:center;padding:6px 11px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}'+
29392          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29393          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29394          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29395          'th{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29396          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29397          'tr:nth-child(even) td{background:#faf8f6;}'+
29398          '.rfoot{position:fixed;left:0;right:0;bottom:0;height:20px;background:#1a2035;color:#9fb0c8;font-size:9px;display:flex;justify-content:space-between;align-items:center;padding:0 14px;box-sizing:border-box;z-index:99;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29399          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29400          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29401          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29402          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29403          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29404          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29405          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29406          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29407          '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29408          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29409          '.fcc-n{font-size:18px;font-weight:900;}'+
29410          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29411        var fileRows=dchg.map(function(r){
29412          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29413          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29414            '<td style="'+ss+'">'+esc(st)+'</td>'+
29415            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29416            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29417            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29418        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29419        var more='';
29420        var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+fmtN(e.c)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
29421        var extraCards='';
29422        if(Number(sd.btests||0)>0||Number(sd.ctests||0)>0){extraCards+='<div class="mcard"><div class="mc-l">Tests Detected</div><div class="mc-v">'+fullN(sd.ctests)+'</div><div class="mc-b">Before: '+fullN(sd.btests)+'</div><div class="mc-p '+pcls(sd.btests,sd.ctests)+'">'+pct(sd.btests,sd.ctests)+'</div></div>';}
29423        if(sd.bcov!=null||sd.ccov!=null){var _cc=(sd.ccov!=null?Number(sd.ccov).toFixed(1)+'%':'—'),_cb=(sd.bcov!=null?Number(sd.bcov).toFixed(1)+'%':'—');extraCards+='<div class="mcard"><div class="mc-l">Coverage</div><div class="mc-v">'+_cc+'</div><div class="mc-b">Before: '+_cb+'</div></div>';}
29424        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29425          '<div class="pdf-header">'+
29426          '<div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Scan Delta</div><div class="ph-date">'+esc(now)+'</div></div>'+
29427          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29428          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29429          '</div>'+
29430          '<div class="body">'+
29431          '<div class="sec"><p class="sh">Summary Metrics</p>'+
29432          '<div class="msec">'+
29433          '<div class="mcard"><div class="mc-l">Code Lines</div><div class="mc-v">'+fullN(sd.cc)+'</div><div class="mc-b">Before: '+fullN(sd.bc)+'</div><div class="mc-p '+pcls(sd.bc,sd.cc)+'">'+pct(sd.bc,sd.cc)+'</div></div>'+
29434          '<div class="mcard"><div class="mc-l">Files Analyzed</div><div class="mc-v">'+fullN(sd.cf)+'</div><div class="mc-b">Before: '+fullN(sd.bf)+'</div><div class="mc-p '+pcls(sd.bf,sd.cf)+'">'+pct(sd.bf,sd.cf)+'</div></div>'+
29435          '<div class="mcard"><div class="mc-l">Comment Lines</div><div class="mc-v">'+fullN(sd.ccm)+'</div><div class="mc-b">Before: '+fullN(sd.bcm)+'</div><div class="mc-p '+pcls(sd.bcm,sd.ccm)+'">'+pct(sd.bcm,sd.ccm)+'</div></div>'+
29436          '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v" style="color:#2a6846">+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
29437          '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v" style="color:#b23030">−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
29438          '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v" style="color:#1a2035">'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
29439          extraCards+'</div></div>'+
29440          '<div class="sec"><p class="sh">File Changes</p>'+
29441          '<div class="fcsec">'+
29442          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29443          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29444          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29445          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29446          '</div></div>'+
29447          (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
29448          '<div class="sec">'+
29449          '<table><thead>'+
29450          '<tr class="pg-rhdr"><th colspan="6"><div class="pg-rhdr-in"><span>File Delta &middot; '+fmtN(dchg.length)+' changed of '+fmtN(dr.length)+' files</span><span class="pg-rhdr-r"><em>oxide</em>-sloc &middot; Scan Delta &middot; '+esc(projName)+'</span></div></th></tr>'+
29451          '<tr><th>File</th><th>Language</th><th>Status</th>'+
29452          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29453          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29454          '</div>'+
29455          '<div class="rfoot">'+
29456          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29457          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29458          '</div>'+
29459          '</body></html>';
29460      }
29461      function doDeltaPdf(btn) {
29462        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29463      }
29464      var pdfBtn = document.getElementById('delta-pdf-btn');
29465      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29466      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29467      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29468      if (location.protocol === 'file:') {
29469        [pageHtmlBtn, chartsBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='Already viewing an exported HTML file'; b.textContent='Export HTML'; } });
29470        [pdfBtn, pagePdfBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='PDF export requires a running server'; b.textContent='Export PDF'; } });
29471      }
29472      var ppSel = document.getElementById('per-page-sel');
29473      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29474      var pathLink = document.getElementById('project-path-link');
29475      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29476    })();
29477
29478    // ── Export helpers ────────────────────────────────────────────────────────
29479    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
29480    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29481    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);}
29482    function slocMakeXlsx(fname,sd,dr){
29483      var enc=new TextEncoder();
29484      // CRC-32 table
29485      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;}
29486      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;}
29487      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29488      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29489      // Shared string table
29490      var ss=[],si={};
29491      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29492      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29493      // Worksheet builder — each WS() call gets its own row counter R
29494      function WS(){
29495        var R=0,buf=[];
29496        function cl(c){return String.fromCharCode(65+c);}
29497        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29498          '<v>'+S(v)+'</v></c>';}
29499        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29500          (st?' s="'+st+'"':'')+'>'+
29501          '<v>'+(+v)+'</v></c>';}
29502        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29503        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29504          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29505          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29506          '<sheetFormatPr defaultRowHeight="15"/>'+
29507          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29508        return{sc:sc,nc:nc,row:row,xml:xml};
29509      }
29510      // Language breakdown
29511      var lm={};
29512      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;});
29513      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29514      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29515      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29516      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29517      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29518      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29519      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):'';}
29520      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29521      // Summary sheet
29522      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29523      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29524      r1(s1(0,proj,2));
29525      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29526      r1('');
29527      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29528      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))));
29529      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))));
29530      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))));
29531      r1('');
29532      r1(s1(0,'FILE CHANGES',8));
29533      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29534      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29535      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29536      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29537      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29538      if(langs.length){
29539        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29540        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29541        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)));});
29542      }
29543      r1('');r1(s1(0,'SCAN METADATA',8));
29544      r1(s1(1,_blabel)+s1(2,_clabel));
29545      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29546      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29547      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"/>');
29548      // File Delta sheet
29549      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29550      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));
29551      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)));});
29552      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29553      // Shared strings XML
29554      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29555        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29556        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29557      // XLSX file map
29558      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29559      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>',
29560        '_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>',
29561        '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>',
29562        '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>',
29563        '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>',
29564        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29565      // ZIP packer — STORED (no compression), compatible with all XLSX readers
29566      var zparts=[],zcds=[],zoff=0,znf=0;
29567      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29568       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29569      ].forEach(function(name){
29570        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29571        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]);
29572        var entry=new Uint8Array(lha.length+nb.length+sz);
29573        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29574        zparts.push(entry);
29575        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));
29576        var cde=new Uint8Array(cda.length+nb.length);
29577        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29578        zcds.push(cde);zoff+=entry.length;znf++;
29579      });
29580      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29581      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]);
29582      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29583      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29584      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29585      zout.set(new Uint8Array(ea),zpos);
29586      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29587      var xurl=URL.createObjectURL(xblob);
29588      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29589      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29590      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29591    }
29592    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;');}
29593    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29594    function getExportFilename(ext){return _exportBase+'.'+ext;}
29595
29596    var _sd = {bc:{{ baseline_code }},cc:{{ current_code }},cd:'{{ code_lines_delta_str }}',bf:{{ baseline_files }},cf:{{ current_files }},fd:'{{ files_analyzed_delta_str }}',bcm:{{ baseline_comments }},ccm:{{ current_comments }},cmd:'{{ comment_lines_delta_str }}',fm:{{ files_modified }},fa:{{ files_added }},fr:{{ files_removed }},fu:{{ files_unchanged }},bts:'{{ baseline_timestamp }}',cts:'{{ current_timestamp }}',bid:'{{ baseline_run_id_short }}',cid:'{{ current_run_id_short }}',bbr:'{{ baseline_git_branch }}',cbr:'{{ current_git_branch }}',btag:'{% if let Some(t) = baseline_git_tags %}{{ t }}{% endif %}',ctag:'{% if let Some(t) = current_git_tags %}{{ t }}{% endif %}',bsha:'{{ baseline_git_commit }}',csha:'{{ current_git_commit }}',btests:{{ baseline_test_count }},ctests:{{ current_test_count }},bcov:{% if let Some(p) = baseline_coverage_pct %}{{ p }}{% else %}null{% endif %},ccov:{% if let Some(p) = current_coverage_pct %}{{ p }}{% else %}null{% endif %},cla:{{ code_lines_added }},clr:{{ code_lines_removed }},churn:'{{ churn_rate_str }}'};
29597    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;}
29598    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29599    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29600    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29601    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29602    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):'';}
29603    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29604    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)]];}
29605    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29606    function getDeltaExportRows(){return DELTA.map(function(d){var b=parseInt(d.bcs)||0,c=parseInt(d.ccs)||0;return [d.path,d.lang,d.status,d.bcs,d.ccs,d.cds,d.cmds,d.tds,_filePct(b,c,d.status)];});}
29607    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29608    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29609
29610    // ── Chart HTML report ─────────────────────────────────────────────────────
29611    function slocChartReport(fname, sd, dr) {
29612      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29613      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29614      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29615      function fmt(n){return Number(n).toLocaleString();}
29616      function px(n){return Math.round(n);}
29617      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29618      // Language map
29619      var lm={};
29620      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;});
29621      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29622
29623      // Builds onmouse* attrs for interactive tooltip on each SVG element
29624      function barTT(label,val){
29625        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29626      }
29627
29628      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29629      //    match the Language Code Delta column height) ────────────
29630      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#E3A876',cc:'#C45C10'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#9FC3AE',cc:'#2A6846'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#E0C58A',cc:'#BE8A2E'}];
29631      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29632      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29633      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29634      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29635      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"/>';}
29636      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29637      c1mets.forEach(function(m,i){
29638        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29639        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29640        var gMax=Math.max(m.b,m.c)*1.15||1;
29641        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29642        c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="'+FONT_C+'" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
29643        c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
29644        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
29645        c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
29646        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29647        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Before</text>';
29648        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">After</text>';
29649      });
29650      c1+='<text x="'+px(C1W/2)+'" y="'+(C1H-8)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Each metric uses its own scale — compare Before vs After within a metric</text>';
29651      c1+='</svg>';
29652
29653      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29654      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#C45C10'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#2A6846'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#BE8A2E'}];
29655      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29656      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29657      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29658      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29659      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29660      mets.forEach(function(m,i){
29661        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29662        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29663        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29664        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
29665        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29666        if(bw>=52){
29667          c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29668        }else{
29669          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29670          c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29671        }
29672      });
29673      c2+='</svg>';
29674
29675      // ── Chart 3: Language Code Delta ─────────────────────────────────────
29676      var c3='';
29677      if(langs.length){
29678        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29679        var C3W=550,c3LW=124,c3FW=52;
29680        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29681        var L3rH=30,C3H=langs.length*L3rH+20;
29682        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29683        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29684        langs.forEach(function(l,i){
29685          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29686          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29687          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29688          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#444">'+esc(l)+'</text>';
29689          c3+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"'+barTT(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+'/>';
29690          if(bw>=48){
29691            c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29692          }else{
29693            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29694            c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29695          }
29696          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29697        });
29698        c3+='</svg>';
29699      }
29700
29701      // ── Chart 4: File Change Donut — centered pie with legend below
29702      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;});
29703      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29704      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29705      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">';
29706      var ang=-Math.PI/2;
29707      segs.forEach(function(s){
29708        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29709        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29710        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29711        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29712        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29713        c4+='<path class="cb" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="white" stroke-width="2.5"'+barTT(s.l,fmt(s.v)+' files \u2022 '+px(s.v/tot*100)+'%')+'/>';
29714        ang+=sw;
29715      });
29716      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
29717      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" fill="#888">total files</text>';
29718      segs.forEach(function(s,i){
29719        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29720        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29721        c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
29722      });
29723      c4+='</svg>';
29724
29725      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29726      var ttJs='var tt=document.getElementById("ox-tt");'+
29727        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29728        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29729        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29730        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29731        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29732        'function oxHT(){tt.style.display="none";}';
29733
29734      // body max-width keeps charts from inflating beyond design dimensions on
29735      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29736      // each chart's height blows up proportionally, breaking the one-page layout.
29737      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;}'+
29738        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29739        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29740        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29741        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29742        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29743        'svg{display:block;}'+
29744        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29745        '#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;}'+
29746        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29747      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29748        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29749        '<div id="ox-tt"><\/div>'+
29750        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
29751        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
29752        '<div class="two-col">'+
29753        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
29754        '<div class="leg">'+
29755        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29756        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29757        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29758        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
29759        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29760        '<\/div>'+
29761        '<div class="two-col">'+
29762        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29763        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29764        '<\/div>'+
29765        '<script>'+ttJs+'<\/script>'+
29766        '<\/body><\/html>';
29767      slocDownload(html, fname, 'text/html;charset=utf-8;');
29768    }
29769    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29770    window.buildDeltaChartsHtml = function() {
29771      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29772      var sd=_sd;
29773      var projEl=document.querySelector('[data-folder]');
29774      var proj=projEl?projEl.getAttribute('data-folder'):'';
29775      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29776      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29777      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29778      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29779      var ttJs='var tt=document.getElementById("ox-tt");function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+"px";tt.style.top=y+"px";}function oxHT(){tt.style.display="none";}';
29780      var css='*{box-sizing:border-box;}body{font-family:Inter,Calibri,Arial,sans-serif;margin:0 auto;padding:20px 30px 24px;max-width:1460px;background:#F7F3EE;color:#333;}h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}svg{display:block;}.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}#ox-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;max-width:240px;white-space:nowrap;}.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29781      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29782        '<div id="ox-tt"><\/div>'+
29783        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29784        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29785        '<div class="two-col">'+
29786        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29787        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29788        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29789        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29790        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29791        '<\/div>'+
29792        '<div class="two-col">'+
29793        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29794        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29795        '<\/div>'+
29796        '<script>'+ttJs+'<\/script>'+
29797        '<\/body><\/html>';
29798    };
29799    // ── Inline delta charts ────────────────────────────────────────────────────
29800    var _icTT=document.getElementById('ic-tt');
29801    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29802    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';};
29803    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29804    window.addEventListener('blur',function(){window.icHT();});
29805    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29806    (function(){
29807      // Theme-aware palette — matches the canonical scheme used by /test-metrics
29808      // charts so every page renders bars/text/grid with the same colours and
29809      // adapts to dark mode (see Design section in CLAUDE.md).
29810      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29811      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29812      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29813      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29814      // washed) so the chart reads with the same weight as /test-metrics.
29815      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29816      var FADE=dark?'#524238':'#e6d0bf';
29817      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29818      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29819      function fmt(n){return Number(n).toLocaleString();}
29820      function px(n){return Math.round(n);}
29821      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29822      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29823      function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t){var ttl=t.getAttribute('data-ttl');icTT(e,ttl,t.getAttribute('data-ttv'));el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});el.querySelectorAll('[data-ttl]').forEach(function(x){if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';});}else{icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';})}});el.addEventListener('mouseleave',function(){icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});});el.addEventListener('mousemove',function(e){icMT(e);});}
29824      var dr=getDeltaExportRows(),sd=_sd,lm={};
29825      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;});
29826      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29827      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29828      // the bars are as tall as the (usually taller) Language Code Delta sibling that
29829      // shares the same grid row, instead of sitting short at the top.
29830      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:OXD,cc:OX},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:GND,cc:GN},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:GDD,cc:GD}];
29831      function drawC1(){
29832        var C1W=600,C1H=188;
29833        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29834        if(host&&card&&host.clientWidth>0){
29835          var avW=host.clientWidth;
29836          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29837          var wantH=availPx*C1W/avW;
29838          if(wantH>C1H)C1H=wantH;
29839        }
29840        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29841        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29842        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"/>';}
29843        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29844        c1mets.forEach(function(m,i){
29845          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29846          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29847          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29848          var gMax=Math.max(m.b,m.c)*1.15||1;
29849          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29850          c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29851          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"/>';
29852          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+fmt(m.b)+'</text>';
29853          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"/>';
29854          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29855          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">Before</text>';
29856          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">After</text>';
29857        });
29858        c1+='<text x="'+px(C1W/2)+'" y="'+px(C1H-6)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" fill="'+mutedCol+'">Each metric uses its own scale — compare Before vs After within a metric</text>';
29859        c1+='</svg>';
29860        return c1;
29861      }
29862      var c1=drawC1();
29863      // Chart 2: Delta by Metric
29864      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:OX},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:GN},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:GD}];
29865      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29866      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29867      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29868      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29869      mets.forEach(function(m,i){
29870        var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29871        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29872        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29873        if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29874        else{var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+textCol+'">'+esc(vStr)+'</text>';}
29875      });
29876      c2+='</svg>';
29877      // Chart 3: Language Code Delta
29878      var c3='';
29879      if(langs.length){
29880        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29881        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;
29882        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29883        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29884        langs.forEach(function(l,i){
29885          var e=lm[l],y=8+i*L3rH,bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29886          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
29887          c3+='<rect'+btt(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
29888          if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29889          else{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}
29890          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29891        });
29892        c3+='</svg>';
29893      }
29894      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29895      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29896      var segs=[{l:'Modified',v:sd.fm,c:OX},{l:'Added',v:sd.fa,c:GN},{l:'Removed',v:sd.fr,c:RD},{l:'Unchanged',v:sd.fu,c:FADE}].filter(function(s){return s.v>0;});
29897      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29898      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29899      var legX=212,legCount=segs.length,legSpacing=Math.max(18,Math.min(30,Math.floor((DH-24)/Math.max(legCount,1)))),legYStart=Math.round((DH-legCount*legSpacing)/2);
29900      var c4='<svg viewBox="0 0 '+DW+' '+DH+'" width="100%" style="display:block;max-width:480px;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
29901      if(segs.length===1){
29902        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29903        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files \u2022 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+rm+'" fill="none" stroke="'+segs[0].c+'" stroke-width="'+rsw+'"/>';
29904      } else {
29905        // Give every visible slice a small minimum sweep, taken from the largest
29906        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29907        // arc whose start and end points coincide, so SVG renders nothing (blank).
29908        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29909        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29910        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29911        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29912        segs.forEach(function(s,si){
29913          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29914          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);
29915          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);
29916          var pct=Math.round(s.v/tot*100);
29917          c4+='<path'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2"/>';
29918          if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text x="'+px(cx4+mR*Math.cos(mAng))+'" y="'+px(cy4+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT4+'" font-size="11" font-weight="700" fill="'+(s.c===FADE?textCol:'#fff')+'" style="pointer-events:none;">'+pct+'%</text>';}
29919          ang+=sw;
29920        });
29921      }
29922      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29923      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29924      segs.forEach(function(s,i){
29925        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29926        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29927        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29928        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29929        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29930        c4+='<text x="'+(legX+92)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(12,legSpacing-4)+'" font-weight="700" fill="'+mutedCol+'">'+fmt(s.v)+' ('+pct+'%)</text>';
29931        c4+='</g>';
29932      });
29933      c4+='</svg>';
29934      // Inject the fixed-height siblings first so the grid row settles to the (taller)
29935      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29936      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29937      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);}
29938      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29939      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29940      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29941
29942      // Compare Timeline chart (Baseline vs Current, 2 points)
29943      (function() {
29944        var activeCmpMetric='code';
29945        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29946        function renderCmpTL(metric, targetSvg, targetH) {
29947          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29948          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29949          svg.setAttribute('height',H);
29950          var pad={l:62,r:20,t:32,b:72};
29951          var dark=document.body.classList.contains('dark-theme');
29952          var cmpPts=[
29953            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29954            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29955          ];
29956          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29957          var valid=pts.filter(function(v){return v!=null;});
29958          if(!valid.length){var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(cmpMetricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'Neither the baseline nor current scan reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+_nd_msg+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+_nd_sub+'</text>';return;}
29959          var minV=0,maxV=Math.max.apply(null,valid);
29960          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29961          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29962          var cx0=pad.l,cx1=pad.l+plotW;
29963          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29964          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29965          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29966          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29967          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29968          function fmtN(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
29969          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29970          var parts=[];
29971          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29972          for(var gi=0;gi<5;gi++){
29973            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29974            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29975            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29976          }
29977          parts.push('<path d="M '+cx0.toFixed(1)+' '+(pad.t+plotH)+' L '+cx0.toFixed(1)+' '+cy0.toFixed(1)+' L '+cx1.toFixed(1)+' '+cy1.toFixed(1)+' L '+cx1.toFixed(1)+' '+(pad.t+plotH)+' Z" fill="'+areaColor+'"/>');
29978          parts.push('<line x1="'+cx0.toFixed(1)+'" y1="'+cy0.toFixed(1)+'" x2="'+cx1.toFixed(1)+'" y2="'+cy1.toFixed(1)+'" stroke="#d37a4c" stroke-width="2.2"/>');
29979          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29980                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29981          dotPts.forEach(function(pt){
29982            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(pt.cy-11).toFixed(1)+'" text-anchor="'+pt.anchor+'" font-size="11" font-weight="600" fill="'+textColor+'">'+Number(pt.v).toLocaleString()+'</text>');
29983            parts.push('<circle cx="'+pt.cx.toFixed(1)+'" cy="'+pt.cy.toFixed(1)+'" r="5" fill="#d37a4c" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5"/>');
29984            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+18)+'" text-anchor="'+pt.anchor+'" font-size="15" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escH(pt.lbl)+'</text>');
29985            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+32)+'" text-anchor="'+pt.anchor+'" font-size="9" font-weight="700" fill="'+textColor+'">'+escH(pt.lbl2)+'</text>');
29986          });
29987          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29988          svg.setAttribute('viewBox','0 0 '+W+' '+H);
29989          svg.innerHTML=parts.join('');
29990          // Hover: crosshair + tooltip (matches multi-scan timeline)
29991          var cmpTT=document.getElementById('ic-tt');
29992          svg.onmousemove=function(e){
29993            var rect=svg.getBoundingClientRect();
29994            var scaleX=W/rect.width;
29995            var mouseX=(e.clientX-rect.left)*scaleX;
29996            var nearest=-1,minDist=Infinity;
29997            var cxArr=[cx0,cx1];
29998            for(var k=0;k<2;k++){if(pts[k]==null)continue;var dx=Math.abs(cxArr[k]-mouseX);if(dx<minDist){minDist=dx;nearest=k;}}
29999            if(nearest<0)return;
30000            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30001            var xhair=svg.querySelector('.cmp-xhair');
30002            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30003            xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
30004            if(!cmpTT)return;
30005            var clbl=cmpPts[nearest].label;
30006            var scanLbl=nearest===0?'Baseline':'Current';
30007            cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
30008            var bx=rect.left+(nc/W*rect.width)+18;
30009            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30010            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30011          };
30012          svg.onmouseleave=function(){
30013            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30014            if(cmpTT)cmpTT.style.display='none';
30015          };
30016        }
30017        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30018          btn.addEventListener('click',function(){
30019            activeCmpMetric=this.dataset.cmpMetric;
30020            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30021            this.classList.add('active');
30022            renderCmpTL(activeCmpMetric);
30023          });
30024        });
30025        var ttgl=document.getElementById('theme-toggle');
30026        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30027        if(typeof ResizeObserver!=='undefined'){
30028          var cmpSvg=document.getElementById('cmp-tl-svg');
30029          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30030        }
30031        // Expose the timeline renderer + current metric so the Full View modal can
30032        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30033        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30034        window.__sdGetMetric=function(){return activeCmpMetric;};
30035        renderCmpTL(activeCmpMetric);
30036      })();
30037
30038      // HTML legend hover -> highlight matching SVG bars within the SAME card only
30039      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30040        var metric=leg.getAttribute('data-highlight');
30041        var parentCard=leg.closest('.ic-card');
30042        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30043        if(!chartEl)return;
30044        leg.addEventListener('mouseenter',function(){
30045          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30046            if(x.getAttribute('data-ttl').indexOf(metric)===0){x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';x.style.opacity='1';}
30047            else{x.style.opacity='0.28';}
30048          });
30049        });
30050        leg.addEventListener('mouseleave',function(){
30051          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30052        });
30053      });
30054
30055      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30056      (function(){
30057        var ov=document.getElementById('ic-svg-modal-ov');
30058        var body=document.getElementById('ic-svg-modal-body');
30059        var ttl=document.getElementById('ic-svg-modal-title');
30060        var closeBtn=document.getElementById('ic-svg-modal-close');
30061        if(!ov||!body)return;
30062        function close(){
30063          ov.classList.remove('open');body.innerHTML='';
30064          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30065          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30066        }
30067        function open(srcId,title){
30068          var src=document.getElementById(srcId);if(!src)return;
30069          if(ttl)ttl.textContent=title||'';
30070          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30071          // snapshot stretches and loses interactivity. Re-render it live into the modal at
30072          // full size instead — keeps proportions, animation, crosshair, tooltip and the
30073          // metric tabs working exactly like the inline chart.
30074          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30075            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30076            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30077            var btnsHtml=mets.map(function(p){return '<button class="chart-metric-btn'+(p[0]===curM?' active':'')+'" data-fv-metric="'+p[0]+'">'+p[1]+'</button>';}).join('');
30078            body.innerHTML='<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">'+btnsHtml+'</div><div class="chart-wrap" style="width:100%;"><svg id="cmp-tl-fv-svg" width="100%" height="440" style="display:block;width:100%;"></svg></div>';
30079            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30080            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30081            ov.classList.add('open');
30082            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30083            if(typeof ResizeObserver!=='undefined'){var ro=new ResizeObserver(function(){if(window.__sdFvTL)window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);});ro.observe(fvSvg);window.__sdFvTL.ro=ro;}
30084            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30085              b.addEventListener('click',function(){
30086                if(!window.__sdFvTL)return;
30087                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30088                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30089                this.classList.add('active');
30090                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30091              });
30092            });
30093            return;
30094          }
30095          var card=src.closest('.ic-card');
30096          var legHtml='';
30097          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30098          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30099          if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No chart data to display.</p>';ov.classList.add('open');return;}
30100          body.innerHTML=legHtml+inner;
30101          var svg=body.querySelector('svg');
30102          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30103          addTT(body);
30104          ov.classList.add('open');
30105        }
30106        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30107          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30108        });
30109        if(closeBtn)closeBtn.addEventListener('click',close);
30110        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30111        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30112      })();
30113
30114      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30115    })();
30116  </script>
30117  {{ toast_assets|safe }}
30118  <script nonce="{{ csp_nonce }}">
30119  (function(){
30120    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'}];
30121    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);});}
30122    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30123    function init(){
30124      var btn=document.getElementById('settings-btn');if(!btn)return;
30125      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30126      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>';
30127      document.body.appendChild(m);
30128      var g=document.getElementById('scheme-grid');
30129      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);});
30130      var cl=document.getElementById('settings-close');
30131      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);
30132      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');});
30133      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30134      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30135    }
30136    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30137  }());
30138  </script>
30139  <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]';
30140  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
30141  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>
30142</body>
30143</html>
30144"##,
30145    ext = "html"
30146)]
30147// Template structs need many bool fields to pass Askama rendering flags.
30148#[allow(clippy::struct_excessive_bools)]
30149struct CompareTemplate {
30150    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30151    loading_overlay: String,
30152    version: &'static str,
30153    project_label: String,
30154    baseline_git_commit: String,
30155    current_git_commit: String,
30156    baseline_run_id: String,
30157    current_run_id: String,
30158    baseline_run_id_short: String,
30159    current_run_id_short: String,
30160    baseline_timestamp: String,
30161    baseline_timestamp_utc_ms: i64,
30162    current_timestamp: String,
30163    current_timestamp_utc_ms: i64,
30164    project_path: String,
30165    baseline_code: u64,
30166    current_code: u64,
30167    code_lines_delta_str: String,
30168    code_lines_delta_class: String,
30169    baseline_files: u64,
30170    current_files: u64,
30171    files_analyzed_delta_str: String,
30172    files_analyzed_delta_class: String,
30173    baseline_comments: u64,
30174    current_comments: u64,
30175    comment_lines_delta_str: String,
30176    comment_lines_delta_class: String,
30177    baseline_code_fmt: String,
30178    current_code_fmt: String,
30179    baseline_files_fmt: String,
30180    current_files_fmt: String,
30181    baseline_comments_fmt: String,
30182    current_comments_fmt: String,
30183    code_lines_pct_str: String,
30184    files_analyzed_pct_str: String,
30185    comment_lines_pct_str: String,
30186    code_lines_added: i64,
30187    code_lines_removed: i64,
30188    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30189    new_scope: bool,
30190    churn_rate_str: String,
30191    churn_rate_class: String,
30192    scope_flag: bool,
30193    files_added: usize,
30194    files_removed: usize,
30195    files_modified: usize,
30196    files_unchanged: usize,
30197    file_rows: Vec<CompareFileDeltaRow>,
30198    baseline_git_author: Option<String>,
30199    current_git_author: Option<String>,
30200    baseline_git_branch: String,
30201    current_git_branch: String,
30202    baseline_git_tags: Option<String>,
30203    current_git_tags: Option<String>,
30204    baseline_git_commit_date: Option<String>,
30205    current_git_commit_date: Option<String>,
30206    project_name: String,
30207    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30208    submodule_options: Vec<String>,
30209    /// True when either run has submodule data — controls whether the scope bar is shown.
30210    has_any_submodule_data: bool,
30211    /// The submodule currently being compared, if the `sub` query param was provided.
30212    active_submodule: Option<String>,
30213    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30214    super_scope_active: bool,
30215    csp_nonce: String,
30216    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
30217    toast_assets: String,
30218    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
30219    coverage_delta_card: String,
30220    baseline_test_count: u64,
30221    current_test_count: u64,
30222    baseline_coverage_pct: Option<f64>,
30223    current_coverage_pct: Option<f64>,
30224}
30225
30226// ── LoginTemplate ──────────────────────────────────────────────────────────────
30227
30228#[derive(Template)]
30229#[template(
30230    source = r##"
30231<!doctype html>
30232<html lang="en">
30233<head>
30234  <meta charset="utf-8">
30235  <meta name="viewport" content="width=device-width, initial-scale=1">
30236  <title>OxideSLOC | Sign In</title>
30237  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30238  <style nonce="{{ csp_nonce }}">
30239    :root {
30240      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30241      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30242      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30243      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30244    }
30245    *{box-sizing:border-box;}
30246    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);}
30247    .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);}
30248    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30249    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30250    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30251    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30252    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30253    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30254    .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;}
30255    @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));}}
30256    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30257    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30258    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30259    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30260    .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;}
30261    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30262    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;}
30263    input[type=password]:focus{border-color:var(--oxide);}
30264    .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;}
30265    .btn:hover{opacity:.88;}
30266    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30267    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30268  </style>
30269</head>
30270<body>
30271  <div class="background-watermarks" aria-hidden="true">
30272    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30273    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30274    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30275    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30276    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30277    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30278    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30279  </div>
30280  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30281<nav class="top-nav">
30282  <a class="brand" href="/">
30283    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30284    <span class="brand-title">OxideSLOC</span>
30285  </a>
30286</nav>
30287<main class="page">
30288  <div class="card">
30289    <h1>Sign In</h1>
30290    <p class="subtitle">Enter the API key printed when the server started.</p>
30291    {% if has_error %}
30292    <div class="error">Incorrect API key — please try again.</div>
30293    {% endif %}
30294    <form method="POST" action="/auth/login">
30295      <input type="hidden" name="next" value="{{ next_url|e }}">
30296      <label for="key">API Key</label>
30297      <input id="key" type="password" name="key" autocomplete="current-password"
30298             placeholder="Paste your API key here" autofocus>
30299      <button type="submit" class="btn">Sign In</button>
30300    </form>
30301    <p class="hint">
30302      The API key was printed in the terminal when the server started.<br>
30303      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30304      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30305    </p>
30306  </div>
30307</main>
30308<script nonce="{{ csp_nonce }}">
30309(function() {
30310  (function randomizeWatermarks() {
30311    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30312    if (!wms.length) return;
30313    var placed = [];
30314    function tooClose(top, left) {
30315      for (var i = 0; i < placed.length; i++) {
30316        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30317        if (dt < 16 && dl < 12) return true;
30318      }
30319      return false;
30320    }
30321    function pick(leftBand) {
30322      for (var attempt = 0; attempt < 50; attempt++) {
30323        var top = Math.random() * 88 + 2;
30324        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30325        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30326      }
30327      var top = Math.random() * 88 + 2;
30328      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30329      placed.push([top, left]); return [top, left];
30330    }
30331    var half = Math.floor(wms.length / 2);
30332    wms.forEach(function (img, i) {
30333      var pos = pick(i < half);
30334      var size = Math.floor(Math.random() * 100 + 120);
30335      var rot = (Math.random() * 360).toFixed(1);
30336      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30337      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;
30338    });
30339  })();
30340  (function spawnCodeParticles() {
30341    var container = document.getElementById('code-particles');
30342    if (!container) return;
30343    var snippets = [
30344      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30345      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30346      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30347      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30348      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30349    ];
30350    var count = 38;
30351    for (var i = 0; i < count; i++) {
30352      (function(idx) {
30353        var el = document.createElement('span');
30354        el.className = 'code-particle';
30355        el.textContent = snippets[idx % snippets.length];
30356        var left = Math.random() * 94 + 2;
30357        var top = Math.random() * 88 + 6;
30358        var dur = (Math.random() * 10 + 9).toFixed(1);
30359        var delay = (Math.random() * 18).toFixed(1);
30360        var rot = (Math.random() * 26 - 13).toFixed(1);
30361        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30362        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30363        container.appendChild(el);
30364      })(i);
30365    }
30366  })();
30367})();
30368</script>
30369</body>
30370</html>
30371"##,
30372    ext = "html"
30373)]
30374pub(crate) struct LoginTemplate {
30375    pub(crate) csp_nonce: String,
30376    pub(crate) has_error: bool,
30377    pub(crate) next_url: String,
30378    pub(crate) lockout_threshold: u32,
30379}
30380
30381// ── REST API reference page ────────────────────────────────────────────────────
30382
30383#[derive(Template)]
30384#[template(
30385    source = r##"
30386<!doctype html>
30387<html lang="en">
30388<head>
30389  <meta charset="utf-8">
30390  <meta name="viewport" content="width=device-width, initial-scale=1">
30391  <title>OxideSLOC — REST API Reference</title>
30392  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30393  <style nonce="{{ csp_nonce }}">
30394    :root {
30395      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30396      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30397      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30398      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30399      --success:#16a34a;
30400    }
30401    body.dark-theme {
30402      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30403      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30404    }
30405    *{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;}
30406    .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);}
30407    .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;}
30408    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30409    .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));}
30410    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30411    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30412    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30413    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30414    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30415    @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; } }
30416    .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;}
30417    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30418    .nav-pill.active{background:rgba(255,255,255,0.22);}
30419    .nav-dropdown{position:relative;display:inline-flex;}
30420    .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;}
30421    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30422    .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;}
30423    .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;}
30424    .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);}
30425    .nav-dropdown-menu a:last-child{border-bottom:none;}
30426    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30427    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30428    .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;}
30429    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30430    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30431    .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;}
30432    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30433    .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);}
30434    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30435    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30436    .settings-modal-body{padding:14px 16px 16px;}
30437    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30438    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30439    .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;}
30440    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30441    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30442    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30443    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30444    .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;}
30445    .tz-select:focus{border-color:var(--oxide);}
30446    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30447    .page-header{margin-bottom:28px;}
30448    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30449    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30450    .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;}
30451    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30452    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30453    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30454    .callout strong{font-weight:800;}
30455    .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;}
30456    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30457    .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;}
30458    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30459    .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;}
30460    body.dark-theme .base-url-value{color:var(--accent);}
30461    .section{margin-bottom:36px;}
30462    .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);}
30463    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30464    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30465    .ep-header:hover{background:var(--surface-2);}
30466    .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;}
30467    .method.get{background:#dcfce7;color:#166534;}
30468    .method.post{background:#dbeafe;color:#1e40af;}
30469    .method.delete{background:#fee2e2;color:#991b1b;}
30470    body.dark-theme .method.get{background:#14532d;color:#86efac;}
30471    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30472    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30473    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30474    .ep-path .param{color:var(--oxide-2);}
30475    body.dark-theme .ep-path .param{color:var(--oxide);}
30476    .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;}
30477    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30478    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30479    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30480    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30481    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30482    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30483    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30484    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30485    .ep-card.open .chevron{transform:rotate(180deg);}
30486    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30487    .ep-card.open .ep-body{display:block;}
30488    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30489    .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;}
30490    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30491    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30492    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30493    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30494    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);}
30495    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30496    table.params tr:last-child td{border-bottom:none;}
30497    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30498    .pt-type{color:var(--muted-2);font-size:12px;}
30499    .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;}
30500    .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;}
30501    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30502    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30503    details.schema{margin-bottom:14px;}
30504    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;}
30505    details.schema summary:hover{color:var(--text);}
30506    .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;}
30507    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30508    .curl-wrap{position:relative;}
30509    .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;}
30510    .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;}
30511    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30512    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30513    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30514    .webhook-note a{color:var(--accent-2);text-decoration:none;}
30515    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30516    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30517    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30518    .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;}
30519    @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));}}
30520    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30521    .site-footer a{color:var(--muted);}
30522  </style>
30523</head>
30524<body>
30525  <div class="background-watermarks" aria-hidden="true">
30526    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30527    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30532    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30533  </div>
30534  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30535  <div class="top-nav">
30536    <div class="top-nav-inner">
30537      <a class="brand" href="/">
30538        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30539        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30540      </a>
30541      <div class="nav-right">
30542        <a class="nav-pill" href="/">Home</a>
30543        <div class="nav-dropdown">
30544          <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>
30545          <div class="nav-dropdown-menu">
30546            <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>
30547          </div>
30548        </div>
30549        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30550        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30551        <div class="nav-dropdown">
30552          <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>
30553          <div class="nav-dropdown-menu">
30554            <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>
30555          </div>
30556        </div>
30557        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30558          <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>
30559        </button>
30560        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30561          <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>
30562          <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>
30563        </button>
30564      </div>
30565    </div>
30566  </div>
30567
30568  <div class="page">
30569    <div class="page-header">
30570      <h1 class="page-title">REST API Reference</h1>
30571      <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>
30572    </div>
30573
30574    {% if has_api_key %}
30575    <div class="callout key-set">
30576      <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>
30577      <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>
30578    </div>
30579    {% else %}
30580    <div class="callout no-key">
30581      <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>
30582      <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>
30583    </div>
30584    {% endif %}
30585
30586    <div class="base-url-bar">
30587      <span class="base-url-label">Base URL</span>
30588      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30589    </div>
30590
30591    <!-- Health -->
30592    <div class="section">
30593      <h2 class="section-title">Health &amp; Status</h2>
30594      <div class="ep-card">
30595        <div class="ep-header">
30596          <span class="method get">GET</span>
30597          <span class="ep-path">/healthz</span>
30598          <span class="auth-badge public">Public</span>
30599          <span class="ep-desc">Server liveness check</span>
30600          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30601        </div>
30602        <div class="ep-body">
30603          <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>
30604          <p class="params-heading">Response</p>
30605          <div class="schema-block">200 OK
30606Content-Type: text/plain
30607
30608ok</div>
30609          <p class="curl-heading">Example</p>
30610          <div class="curl-wrap">
30611            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30612            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30613          </div>
30614        </div>
30615      </div>
30616    </div>
30617
30618    <!-- Badges -->
30619    <div class="section">
30620      <h2 class="section-title">Badges</h2>
30621      <div class="ep-card">
30622        <div class="ep-header">
30623          <span class="method get">GET</span>
30624          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30625          <span class="auth-badge public">Public</span>
30626          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30627          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30628        </div>
30629        <div class="ep-body">
30630          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30631          <p class="params-heading">Path Parameters</p>
30632          <table class="params">
30633            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30634            <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>
30635          </table>
30636          <p class="curl-heading">Example</p>
30637          <div class="curl-wrap">
30638            <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>
30639            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30640          </div>
30641        </div>
30642      </div>
30643    </div>
30644
30645    <!-- Metrics -->
30646    <div class="section">
30647      <h2 class="section-title">Metrics</h2>
30648
30649      <div class="ep-card">
30650        <div class="ep-header">
30651          <span class="method get">GET</span>
30652          <span class="ep-path">/api/metrics/latest</span>
30653          <span class="auth-badge protected">Protected</span>
30654          <span class="ep-desc">Latest scan metrics (JSON)</span>
30655          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30656        </div>
30657        <div class="ep-body">
30658          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30659          <details class="schema"><summary>Response schema</summary>
30660<div class="schema-block">{
30661  "run_id":    string,        // UUID
30662  "timestamp": string,        // ISO-8601 UTC
30663  "project":   string,        // scanned root path
30664  "summary": {
30665    "files_analyzed":       number,
30666    "files_skipped":        number,
30667    "code_lines":           number,
30668    "comment_lines":        number,
30669    "blank_lines":          number,
30670    "total_physical_lines": number,
30671    "functions":            number,
30672    "classes":              number,
30673    "variables":            number,
30674    "imports":              number
30675  },
30676  "languages": [
30677    { "name": string, "files": number, "code_lines": number,
30678      "comment_lines": number, "blank_lines": number,
30679      "functions": number, "classes": number,
30680      "variables": number, "imports": number }
30681  ]
30682}</div></details>
30683          <p class="curl-heading">Example</p>
30684          <div class="curl-wrap">
30685            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30686  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30687            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30688          </div>
30689        </div>
30690      </div>
30691
30692      <div class="ep-card">
30693        <div class="ep-header">
30694          <span class="method get">GET</span>
30695          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30696          <span class="auth-badge protected">Protected</span>
30697          <span class="ep-desc">Metrics for a specific run</span>
30698          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30699        </div>
30700        <div class="ep-body">
30701          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30702          <p class="params-heading">Path Parameters</p>
30703          <table class="params">
30704            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30705            <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>
30706          </table>
30707          <p class="curl-heading">Example</p>
30708          <div class="curl-wrap">
30709            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30710  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
30711            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30712          </div>
30713        </div>
30714      </div>
30715
30716      <div class="ep-card">
30717        <div class="ep-header">
30718          <span class="method get">GET</span>
30719          <span class="ep-path">/api/metrics/history</span>
30720          <span class="auth-badge protected">Protected</span>
30721          <span class="ep-desc">Paginated scan history</span>
30722          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30723        </div>
30724        <div class="ep-body">
30725          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30726          <p class="params-heading">Query Parameters</p>
30727          <table class="params">
30728            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30729            <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>
30730            <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>
30731          </table>
30732          <details class="schema"><summary>Response schema</summary>
30733<div class="schema-block">[{
30734  "run_id":         string,
30735  "timestamp":      string,   // ISO-8601 UTC
30736  "commit":         string | null,
30737  "branch":         string | null,
30738  "tags":           string[],
30739  "code_lines":     number,
30740  "comment_lines":  number,
30741  "blank_lines":    number,
30742  "physical_lines": number,
30743  "files_analyzed": number,
30744  "project_label":  string,
30745  "html_url":       string | null
30746}]</div></details>
30747          <p class="curl-heading">Example</p>
30748          <div class="curl-wrap">
30749            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30750  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30751            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30752          </div>
30753        </div>
30754      </div>
30755
30756      <div class="ep-card">
30757        <div class="ep-header">
30758          <span class="method get">GET</span>
30759          <span class="ep-path">/api/project-history</span>
30760          <span class="auth-badge protected">Protected</span>
30761          <span class="ep-desc">Project-level scan summary</span>
30762          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30763        </div>
30764        <div class="ep-body">
30765          <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>
30766          <p class="params-heading">Query Parameters</p>
30767          <table class="params">
30768            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30769            <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>
30770          </table>
30771          <details class="schema"><summary>Response schema</summary>
30772<div class="schema-block">{
30773  "scan_count":           number,
30774  "last_scan_id":         string | null,
30775  "last_scan_timestamp":  string | null,  // ISO-8601
30776  "last_scan_code_lines": number | null,
30777  "last_git_branch":      string | null,
30778  "last_git_commit":      string | null
30779}</div></details>
30780          <p class="curl-heading">Example</p>
30781          <div class="curl-wrap">
30782            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30783  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30784            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30785          </div>
30786        </div>
30787      </div>
30788
30789      <div class="ep-card">
30790        <div class="ep-header">
30791          <span class="method get">GET</span>
30792          <span class="ep-path">/api/metrics/submodules</span>
30793          <span class="auth-badge protected">Protected</span>
30794          <span class="ep-desc">List known git submodules across scans</span>
30795          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30796        </div>
30797        <div class="ep-body">
30798          <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>
30799          <p class="params-heading">Query Parameters</p>
30800          <table class="params">
30801            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30802            <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>
30803          </table>
30804          <details class="schema"><summary>Response schema</summary>
30805<div class="schema-block">[{
30806  "name":          string,  // submodule name
30807  "relative_path": string   // path relative to the project root
30808}]</div></details>
30809          <p class="curl-heading">Example</p>
30810          <div class="curl-wrap">
30811            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30812  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30813            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30814          </div>
30815        </div>
30816      </div>
30817    </div>
30818
30819    <!-- Async Run Status -->
30820    <div class="section">
30821      <h2 class="section-title">Async Run Status</h2>
30822
30823      <div class="ep-card">
30824        <div class="ep-header">
30825          <span class="method get">GET</span>
30826          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30827          <span class="auth-badge protected">Protected</span>
30828          <span class="ep-desc">Poll scan completion</span>
30829          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30830        </div>
30831        <div class="ep-body">
30832          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30833          <details class="schema"><summary>Response schema</summary>
30834<div class="schema-block">// Running
30835{ "state": "running",  "elapsed_secs": number }
30836
30837// Complete
30838{ "state": "complete", "run_id": string }
30839
30840// Failed
30841{ "state": "failed",   "message": string }</div></details>
30842          <p class="curl-heading">Example</p>
30843          <div class="curl-wrap">
30844            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30845  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
30846            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30847          </div>
30848        </div>
30849      </div>
30850
30851      <div class="ep-card">
30852        <div class="ep-header">
30853          <span class="method get">GET</span>
30854          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30855          <span class="auth-badge protected">Protected</span>
30856          <span class="ep-desc">Poll PDF generation readiness</span>
30857          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30858        </div>
30859        <div class="ep-body">
30860          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30861          <details class="schema"><summary>Response schema</summary>
30862<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30863          <p class="curl-heading">Example</p>
30864          <div class="curl-wrap">
30865            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30866  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
30867            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30868          </div>
30869        </div>
30870      </div>
30871
30872      <div class="ep-card">
30873        <div class="ep-header">
30874          <span class="method post">POST</span>
30875          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30876          <span class="auth-badge protected">Protected</span>
30877          <span class="ep-desc">Cancel a running scan</span>
30878          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30879        </div>
30880        <div class="ep-body">
30881          <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>
30882          <p class="curl-heading">Example</p>
30883          <div class="curl-wrap">
30884            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30885  -H "Authorization: Bearer $SLOC_API_KEY" \
30886  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
30887            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30888          </div>
30889        </div>
30890      </div>
30891    </div>
30892
30893    <!-- Run Management -->
30894    <div class="section">
30895      <h2 class="section-title">Run Management</h2>
30896
30897      <div class="ep-card">
30898        <div class="ep-header">
30899          <span class="method get">GET</span>
30900          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30901          <span class="auth-badge protected">Protected</span>
30902          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30903          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30904        </div>
30905        <div class="ep-body">
30906          <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>
30907          <p class="params-heading">Path Parameters</p>
30908          <table class="params">
30909            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30910            <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>
30911          </table>
30912          <details class="schema"><summary>Response</summary>
30913<div class="schema-block">200 OK — Content-Type: application/zip
30914Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
30915
30916404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
30917          <p class="curl-heading">Example</p>
30918          <div class="curl-wrap">
30919            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30920  -o run.zip \
30921  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
30922            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30923          </div>
30924        </div>
30925      </div>
30926
30927      <div class="ep-card">
30928        <div class="ep-header">
30929          <span class="method delete">DELETE</span>
30930          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30931          <span class="auth-badge protected">Protected</span>
30932          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30933          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30934        </div>
30935        <div class="ep-body">
30936          <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>
30937          <p class="params-heading">Path Parameters</p>
30938          <table class="params">
30939            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30940            <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>
30941          </table>
30942          <details class="schema"><summary>Response</summary>
30943<div class="schema-block">204 No Content — run successfully deleted
30944
30945500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
30946          <p class="curl-heading">Example</p>
30947          <div class="curl-wrap">
30948            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30949  -H "Authorization: Bearer $SLOC_API_KEY" \
30950  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
30951            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30952          </div>
30953        </div>
30954      </div>
30955
30956      <div class="ep-card">
30957        <div class="ep-header">
30958          <span class="method post">POST</span>
30959          <span class="ep-path">/api/runs/cleanup</span>
30960          <span class="auth-badge protected">Protected</span>
30961          <span class="ep-desc">Bulk delete runs older than N days</span>
30962          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30963        </div>
30964        <div class="ep-body">
30965          <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>
30966          <p class="params-heading">Request Body (application/json)</p>
30967          <table class="params">
30968            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30969            <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>
30970          </table>
30971          <details class="schema"><summary>Response schema</summary>
30972<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
30973          <p class="curl-heading">Example — delete runs older than 60 days</p>
30974          <div class="curl-wrap">
30975            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30976  -H "Authorization: Bearer $SLOC_API_KEY" \
30977  -H "Content-Type: application/json" \
30978  -d '{"older_than_days":60}' \
30979  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30980            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30981          </div>
30982        </div>
30983      </div>
30984    </div>
30985
30986    <!-- Retention Policy -->
30987    <div class="section">
30988      <h2 class="section-title">Retention Policy</h2>
30989
30990      <div class="ep-card">
30991        <div class="ep-header">
30992          <span class="method get">GET</span>
30993          <span class="ep-path">/api/cleanup-policy</span>
30994          <span class="auth-badge protected">Protected</span>
30995          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30996          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30997        </div>
30998        <div class="ep-body">
30999          <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>
31000          <details class="schema"><summary>Response schema</summary>
31001<div class="schema-block">{
31002  "policy": {
31003    "enabled":       boolean,
31004    "max_age_days":  number | null,   // delete runs older than N days
31005    "max_run_count": number | null,   // keep only the N most recent runs
31006    "interval_hours": number          // hours between background passes
31007  } | null,
31008  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
31009  "last_run_deleted": number | null   // runs deleted in last pass
31010}</div></details>
31011          <p class="curl-heading">Example</p>
31012          <div class="curl-wrap">
31013            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31014  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31015            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31016          </div>
31017        </div>
31018      </div>
31019
31020      <div class="ep-card">
31021        <div class="ep-header">
31022          <span class="method post">POST</span>
31023          <span class="ep-path">/api/cleanup-policy</span>
31024          <span class="auth-badge protected">Protected</span>
31025          <span class="ep-desc">Save or update the retention policy</span>
31026          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31027        </div>
31028        <div class="ep-body">
31029          <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>
31030          <p class="params-heading">Request Body (application/json)</p>
31031          <table class="params">
31032            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31033            <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>
31034            <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>
31035            <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>
31036            <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>
31037          </table>
31038          <details class="schema"><summary>Response</summary>
31039<div class="schema-block">204 No Content — policy saved and task (re)started
31040
31041500 Internal Server Error — { "error": string }</div></details>
31042          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31043          <div class="curl-wrap">
31044            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31045  -H "Authorization: Bearer $SLOC_API_KEY" \
31046  -H "Content-Type: application/json" \
31047  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31048  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31049            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31050          </div>
31051        </div>
31052      </div>
31053
31054      <div class="ep-card">
31055        <div class="ep-header">
31056          <span class="method post">POST</span>
31057          <span class="ep-path">/api/cleanup-policy/run-now</span>
31058          <span class="auth-badge protected">Protected</span>
31059          <span class="ep-desc">Trigger an immediate cleanup pass</span>
31060          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31061        </div>
31062        <div class="ep-body">
31063          <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>
31064          <details class="schema"><summary>Response schema</summary>
31065<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
31066          <p class="curl-heading">Example</p>
31067          <div class="curl-wrap">
31068            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31069  -H "Authorization: Bearer $SLOC_API_KEY" \
31070  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31071            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31072          </div>
31073        </div>
31074      </div>
31075
31076      <div class="ep-card">
31077        <div class="ep-header">
31078          <span class="method delete">DELETE</span>
31079          <span class="ep-path">/api/cleanup-policy</span>
31080          <span class="auth-badge protected">Protected</span>
31081          <span class="ep-desc">Remove the retention policy and stop the background task</span>
31082          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31083        </div>
31084        <div class="ep-body">
31085          <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>
31086          <details class="schema"><summary>Response</summary>
31087<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31088          <p class="curl-heading">Example</p>
31089          <div class="curl-wrap">
31090            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31091  -H "Authorization: Bearer $SLOC_API_KEY" \
31092  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31093            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31094          </div>
31095        </div>
31096      </div>
31097    </div>
31098
31099    <!-- Scan Profiles -->
31100    <div class="section">
31101      <h2 class="section-title">Scan Profiles</h2>
31102
31103      <div class="ep-card">
31104        <div class="ep-header">
31105          <span class="method get">GET</span>
31106          <span class="ep-path">/api/scan-profiles</span>
31107          <span class="auth-badge protected">Protected</span>
31108          <span class="ep-desc">List saved scan profiles</span>
31109          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31110        </div>
31111        <div class="ep-body">
31112          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31113          <details class="schema"><summary>Response schema</summary>
31114<div class="schema-block">{
31115  "profiles": [{
31116    "id":         string,   // UUID
31117    "name":       string,
31118    "created_at": string,   // ISO-8601
31119    "params":     object
31120  }]
31121}</div></details>
31122          <p class="curl-heading">Example</p>
31123          <div class="curl-wrap">
31124            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31125  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31126            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31127          </div>
31128        </div>
31129      </div>
31130
31131      <div class="ep-card">
31132        <div class="ep-header">
31133          <span class="method post">POST</span>
31134          <span class="ep-path">/api/scan-profiles</span>
31135          <span class="auth-badge protected">Protected</span>
31136          <span class="ep-desc">Save a scan profile</span>
31137          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31138        </div>
31139        <div class="ep-body">
31140          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31141          <p class="params-heading">Request Body (application/json)</p>
31142          <table class="params">
31143            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31144            <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>
31145            <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>
31146          </table>
31147          <details class="schema"><summary>Response schema</summary>
31148<div class="schema-block">{ "ok": true }</div></details>
31149          <p class="curl-heading">Example</p>
31150          <div class="curl-wrap">
31151            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31152  -H "Authorization: Bearer $SLOC_API_KEY" \
31153  -H "Content-Type: application/json" \
31154  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31155  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31156            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31157          </div>
31158        </div>
31159      </div>
31160
31161      <div class="ep-card">
31162        <div class="ep-header">
31163          <span class="method delete">DELETE</span>
31164          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31165          <span class="auth-badge protected">Protected</span>
31166          <span class="ep-desc">Delete a scan profile</span>
31167          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31168        </div>
31169        <div class="ep-body">
31170          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31171          <p class="params-heading">Path Parameters</p>
31172          <table class="params">
31173            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31174            <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>
31175          </table>
31176          <details class="schema"><summary>Response schema</summary>
31177<div class="schema-block">{ "ok": true }</div></details>
31178          <p class="curl-heading">Example</p>
31179          <div class="curl-wrap">
31180            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31181  -H "Authorization: Bearer $SLOC_API_KEY" \
31182  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31183            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31184          </div>
31185        </div>
31186      </div>
31187    </div>
31188
31189    <!-- Scheduled Scans -->
31190    <div class="section">
31191      <h2 class="section-title">Scheduled Scans</h2>
31192
31193      <div class="ep-card">
31194        <div class="ep-header">
31195          <span class="method get">GET</span>
31196          <span class="ep-path">/api/schedules</span>
31197          <span class="auth-badge protected">Protected</span>
31198          <span class="ep-desc">List configured schedules</span>
31199          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31200        </div>
31201        <div class="ep-body">
31202          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31203          <p class="curl-heading">Example</p>
31204          <div class="curl-wrap">
31205            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31206  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31207            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31208          </div>
31209        </div>
31210      </div>
31211
31212      <div class="ep-card">
31213        <div class="ep-header">
31214          <span class="method post">POST</span>
31215          <span class="ep-path">/api/schedules</span>
31216          <span class="auth-badge protected">Protected</span>
31217          <span class="ep-desc">Create a schedule</span>
31218          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31219        </div>
31220        <div class="ep-body">
31221          <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>
31222          <p class="curl-heading">Example</p>
31223          <div class="curl-wrap">
31224            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31225  -H "Authorization: Bearer $SLOC_API_KEY" \
31226  -H "Content-Type: application/json" \
31227  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31228  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31229            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31230          </div>
31231        </div>
31232      </div>
31233
31234      <div class="ep-card">
31235        <div class="ep-header">
31236          <span class="method delete">DELETE</span>
31237          <span class="ep-path">/api/schedules</span>
31238          <span class="auth-badge protected">Protected</span>
31239          <span class="ep-desc">Delete a schedule</span>
31240          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31241        </div>
31242        <div class="ep-body">
31243          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31244          <p class="curl-heading">Example</p>
31245          <div class="curl-wrap">
31246            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31247  -H "Authorization: Bearer $SLOC_API_KEY" \
31248  -H "Content-Type: application/json" \
31249  -d '{"id":"&lt;schedule_id&gt;"}' \
31250  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31251            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31252          </div>
31253        </div>
31254      </div>
31255    </div>
31256
31257    <!-- Git Browser -->
31258    <div class="section">
31259      <h2 class="section-title">Git Browser</h2>
31260
31261      <div class="ep-card">
31262        <div class="ep-header">
31263          <span class="method get">GET</span>
31264          <span class="ep-path">/api/git/refs</span>
31265          <span class="auth-badge protected">Protected</span>
31266          <span class="ep-desc">List git refs for a repository</span>
31267          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31268        </div>
31269        <div class="ep-body">
31270          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31271          <p class="params-heading">Query Parameters</p>
31272          <table class="params">
31273            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31274            <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>
31275          </table>
31276          <p class="curl-heading">Example</p>
31277          <div class="curl-wrap">
31278            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31279  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31280            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31281          </div>
31282        </div>
31283      </div>
31284
31285      <div class="ep-card">
31286        <div class="ep-header">
31287          <span class="method get">GET</span>
31288          <span class="ep-path">/api/git/scan-ref</span>
31289          <span class="auth-badge protected">Protected</span>
31290          <span class="ep-desc">SLOC-scan a specific git ref</span>
31291          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31292        </div>
31293        <div class="ep-body">
31294          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31295          <p class="params-heading">Query Parameters</p>
31296          <table class="params">
31297            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31298            <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>
31299            <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>
31300          </table>
31301          <p class="curl-heading">Example</p>
31302          <div class="curl-wrap">
31303            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31304  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
31305            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31306          </div>
31307        </div>
31308      </div>
31309
31310      <div class="ep-card">
31311        <div class="ep-header">
31312          <span class="method get">GET</span>
31313          <span class="ep-path">/api/git/compare-refs</span>
31314          <span class="auth-badge protected">Protected</span>
31315          <span class="ep-desc">Compare SLOC across two git refs</span>
31316          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31317        </div>
31318        <div class="ep-body">
31319          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31320          <p class="params-heading">Query Parameters</p>
31321          <table class="params">
31322            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31323            <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>
31324            <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>
31325            <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>
31326          </table>
31327          <p class="curl-heading">Example</p>
31328          <div class="curl-wrap">
31329            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31330  "<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>
31331            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31332          </div>
31333        </div>
31334      </div>
31335    </div>
31336
31337    <!-- Webhooks -->
31338    <div class="section">
31339      <h2 class="section-title">Webhooks</h2>
31340      <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>
31341
31342      <div class="ep-card">
31343        <div class="ep-header">
31344          <span class="method post">POST</span>
31345          <span class="ep-path">/webhooks/github</span>
31346          <span class="auth-badge hmac">HMAC</span>
31347          <span class="ep-desc">GitHub push event receiver</span>
31348          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31349        </div>
31350        <div class="ep-body">
31351          <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>
31352          <p class="params-heading">Required Headers</p>
31353          <table class="params">
31354            <tr><th>Header</th><th>Value</th></tr>
31355            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31356            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31357            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31358          </table>
31359        </div>
31360      </div>
31361
31362      <div class="ep-card">
31363        <div class="ep-header">
31364          <span class="method post">POST</span>
31365          <span class="ep-path">/webhooks/gitlab</span>
31366          <span class="auth-badge hmac">HMAC</span>
31367          <span class="ep-desc">GitLab push event receiver</span>
31368          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31369        </div>
31370        <div class="ep-body">
31371          <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>
31372          <p class="params-heading">Required Headers</p>
31373          <table class="params">
31374            <tr><th>Header</th><th>Value</th></tr>
31375            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31376            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31377            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31378          </table>
31379        </div>
31380      </div>
31381
31382      <div class="ep-card">
31383        <div class="ep-header">
31384          <span class="method post">POST</span>
31385          <span class="ep-path">/webhooks/bitbucket</span>
31386          <span class="auth-badge hmac">HMAC</span>
31387          <span class="ep-desc">Bitbucket push event receiver</span>
31388          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31389        </div>
31390        <div class="ep-body">
31391          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31392          <p class="params-heading">Required Headers</p>
31393          <table class="params">
31394            <tr><th>Header</th><th>Value</th></tr>
31395            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31396            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31397          </table>
31398        </div>
31399      </div>
31400    </div>
31401
31402    <!-- Config -->
31403    <div class="section">
31404      <h2 class="section-title">Config Import / Export</h2>
31405
31406      <div class="ep-card">
31407        <div class="ep-header">
31408          <span class="method get">GET</span>
31409          <span class="ep-path">/export-config</span>
31410          <span class="auth-badge protected">Protected</span>
31411          <span class="ep-desc">Export server configuration as JSON</span>
31412          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31413        </div>
31414        <div class="ep-body">
31415          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31416          <p class="curl-heading">Example</p>
31417          <div class="curl-wrap">
31418            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31419  -o config.json \
31420  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31421            <button class="curl-copy-btn" data-target="c-export">Copy</button>
31422          </div>
31423        </div>
31424      </div>
31425
31426      <div class="ep-card">
31427        <div class="ep-header">
31428          <span class="method post">POST</span>
31429          <span class="ep-path">/import-config</span>
31430          <span class="auth-badge protected">Protected</span>
31431          <span class="ep-desc">Import server configuration</span>
31432          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31433        </div>
31434        <div class="ep-body">
31435          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31436          <p class="curl-heading">Example</p>
31437          <div class="curl-wrap">
31438            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31439  -H "Authorization: Bearer $SLOC_API_KEY" \
31440  -H "Content-Type: application/json" \
31441  -d @config.json \
31442  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31443            <button class="curl-copy-btn" data-target="c-import">Copy</button>
31444          </div>
31445        </div>
31446      </div>
31447    </div>
31448
31449    <!-- CI Ingest -->
31450    <div class="section">
31451      <h2 class="section-title">CI Ingest</h2>
31452
31453      <div class="ep-card">
31454        <div class="ep-header">
31455          <span class="method post">POST</span>
31456          <span class="ep-path">/api/ingest</span>
31457          <span class="auth-badge protected">Protected</span>
31458          <span class="ep-desc">Push a pre-computed scan result from CI</span>
31459          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31460        </div>
31461        <div class="ep-body">
31462          <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>
31463          <p class="params-heading">Query Parameters</p>
31464          <table class="params">
31465            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31466            <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>
31467          </table>
31468          <p class="params-heading">Request Body (application/json)</p>
31469          <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>
31470          <details class="schema"><summary>Response schema</summary>
31471<div class="schema-block">// 201 Created
31472{
31473  "run_id":   string,  // UUID of the ingested run
31474  "view_url": string   // relative URL to the report page
31475}</div></details>
31476          <p class="curl-heading">Example</p>
31477          <div class="curl-wrap">
31478            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31479  -H "Authorization: Bearer $SLOC_API_KEY" \
31480  -H "Content-Type: application/json" \
31481  -d @result.json \
31482  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31483            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31484          </div>
31485        </div>
31486      </div>
31487    </div>
31488
31489    <!-- Artifact Download -->
31490    <div class="section">
31491      <h2 class="section-title">Artifact Download</h2>
31492
31493      <div class="ep-card">
31494        <div class="ep-header">
31495          <span class="method get">GET</span>
31496          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31497          <span class="auth-badge protected">Protected</span>
31498          <span class="ep-desc">Download or view a scan artifact</span>
31499          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31500        </div>
31501        <div class="ep-body">
31502          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31503          <p class="params-heading">Path Parameters</p>
31504          <table class="params">
31505            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31506            <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>
31507            <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>
31508          </table>
31509          <p class="params-heading">Query Parameters</p>
31510          <table class="params">
31511            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31512            <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>
31513          </table>
31514          <p class="curl-heading">Example — download JSON result</p>
31515          <div class="curl-wrap">
31516            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31517  -o result.json \
31518  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
31519            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31520          </div>
31521        </div>
31522      </div>
31523    </div>
31524
31525    <!-- Embed Widget -->
31526    <div class="section">
31527      <h2 class="section-title">Embed Widget</h2>
31528
31529      <div class="ep-card">
31530        <div class="ep-header">
31531          <span class="method get">GET</span>
31532          <span class="ep-path">/embed/summary</span>
31533          <span class="auth-badge protected">Protected</span>
31534          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31535          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31536        </div>
31537        <div class="ep-body">
31538          <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>
31539          <p class="params-heading">Query Parameters</p>
31540          <table class="params">
31541            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31542            <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>
31543            <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>
31544          </table>
31545          <p class="curl-heading">Example</p>
31546          <div class="curl-wrap">
31547            <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"
31548        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
31549            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31550          </div>
31551        </div>
31552      </div>
31553    </div>
31554
31555    <!-- Confluence Integration -->
31556    <div class="section">
31557      <h2 class="section-title">Confluence Integration</h2>
31558
31559      <div class="ep-card">
31560        <div class="ep-header">
31561          <span class="method get">GET</span>
31562          <span class="ep-path">/api/confluence/config</span>
31563          <span class="auth-badge protected">Protected</span>
31564          <span class="ep-desc">Get current Confluence configuration</span>
31565          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31566        </div>
31567        <div class="ep-body">
31568          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31569          <details class="schema"><summary>Response schema</summary>
31570<div class="schema-block">{
31571  "configured":     boolean,
31572  "tier":           "cloud" | "server",
31573  "base_url":       string,
31574  "username":       string,
31575  "api_token_set":  boolean,
31576  "space_key":      string,
31577  "parent_page_id": string | null,
31578  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
31579}</div></details>
31580          <p class="curl-heading">Example</p>
31581          <div class="curl-wrap">
31582            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31583  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31584            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31585          </div>
31586        </div>
31587      </div>
31588
31589      <div class="ep-card">
31590        <div class="ep-header">
31591          <span class="method post">POST</span>
31592          <span class="ep-path">/api/confluence/config</span>
31593          <span class="auth-badge protected">Protected</span>
31594          <span class="ep-desc">Save Confluence configuration</span>
31595          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31596        </div>
31597        <div class="ep-body">
31598          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31599          <p class="params-heading">Request Body (application/json)</p>
31600          <table class="params">
31601            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31602            <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>
31603            <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>
31604            <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>
31605            <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>
31606            <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>
31607            <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>
31608            <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>
31609          </table>
31610          <details class="schema"><summary>Response schema</summary>
31611<div class="schema-block">{ "ok": true }</div></details>
31612          <p class="curl-heading">Example</p>
31613          <div class="curl-wrap">
31614            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31615  -H "Authorization: Bearer $SLOC_API_KEY" \
31616  -H "Content-Type: application/json" \
31617  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31618  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31619            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31620          </div>
31621        </div>
31622      </div>
31623
31624      <div class="ep-card">
31625        <div class="ep-header">
31626          <span class="method post">POST</span>
31627          <span class="ep-path">/api/confluence/test</span>
31628          <span class="auth-badge protected">Protected</span>
31629          <span class="ep-desc">Test Confluence connection</span>
31630          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31631        </div>
31632        <div class="ep-body">
31633          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31634          <details class="schema"><summary>Response schema</summary>
31635<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31636          <p class="curl-heading">Example</p>
31637          <div class="curl-wrap">
31638            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31639  -H "Authorization: Bearer $SLOC_API_KEY" \
31640  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31641            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31642          </div>
31643        </div>
31644      </div>
31645
31646      <div class="ep-card">
31647        <div class="ep-header">
31648          <span class="method post">POST</span>
31649          <span class="ep-path">/api/confluence/post</span>
31650          <span class="auth-badge protected">Protected</span>
31651          <span class="ep-desc">Publish a scan report to Confluence</span>
31652          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31653        </div>
31654        <div class="ep-body">
31655          <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>
31656          <p class="params-heading">Request Body (application/json)</p>
31657          <table class="params">
31658            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31659            <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>
31660            <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>
31661            <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>
31662          </table>
31663          <details class="schema"><summary>Response schema</summary>
31664<div class="schema-block">// 200 OK
31665{ "ok": true, "page_id": string }
31666
31667// 400 / 502 on error
31668{ "ok": false, "error": string }</div></details>
31669          <p class="curl-heading">Example</p>
31670          <div class="curl-wrap">
31671            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31672  -H "Authorization: Bearer $SLOC_API_KEY" \
31673  -H "Content-Type: application/json" \
31674  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
31675  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31676            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31677          </div>
31678        </div>
31679      </div>
31680
31681      <div class="ep-card">
31682        <div class="ep-header">
31683          <span class="method get">GET</span>
31684          <span class="ep-path">/api/confluence/wiki-markup</span>
31685          <span class="auth-badge protected">Protected</span>
31686          <span class="ep-desc">Get Confluence wiki markup for a run</span>
31687          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31688        </div>
31689        <div class="ep-body">
31690          <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>
31691          <p class="params-heading">Query Parameters</p>
31692          <table class="params">
31693            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31694            <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>
31695          </table>
31696          <p class="curl-heading">Example</p>
31697          <div class="curl-wrap">
31698            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31699  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
31700            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31701          </div>
31702        </div>
31703      </div>
31704    </div>
31705
31706    <!-- Authentication -->
31707    <div class="section">
31708      <h2 class="section-title">Authentication</h2>
31709      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31710
31711      <div class="ep-card">
31712        <div class="ep-header">
31713          <span class="method get">GET</span>
31714          <span class="ep-path">/auth/login</span>
31715          <span class="auth-badge public">Public</span>
31716          <span class="ep-desc">Login page</span>
31717          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31718        </div>
31719        <div class="ep-body">
31720          <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>
31721          <p class="params-heading">Query Parameters</p>
31722          <table class="params">
31723            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31724            <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>
31725            <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>
31726          </table>
31727        </div>
31728      </div>
31729
31730      <div class="ep-card">
31731        <div class="ep-header">
31732          <span class="method post">POST</span>
31733          <span class="ep-path">/auth/login</span>
31734          <span class="auth-badge public">Public</span>
31735          <span class="ep-desc">Submit credentials and get a session cookie</span>
31736          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31737        </div>
31738        <div class="ep-body">
31739          <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>
31740          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31741          <table class="params">
31742            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31743            <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>
31744            <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>
31745          </table>
31746          <p class="curl-heading">Example</p>
31747          <div class="curl-wrap">
31748            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31749  -d "key=$SLOC_API_KEY&amp;next=/" \
31750  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31751            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31752          </div>
31753        </div>
31754      </div>
31755    </div>
31756
31757    <!-- Coverage Suggestion -->
31758    <div class="section">
31759      <h2 class="section-title">Coverage Suggestion</h2>
31760
31761      <div class="ep-card">
31762        <div class="ep-header">
31763          <span class="method get">GET</span>
31764          <span class="ep-path">/api/suggest-coverage</span>
31765          <span class="auth-badge protected">Protected</span>
31766          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31767          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31768        </div>
31769        <div class="ep-body">
31770          <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML, coverage.py JSON) and returns the first one found, along with a hint for how to generate it if not present.</p>
31771          <p class="params-heading">Query Parameters</p>
31772          <table class="params">
31773            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31774            <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>
31775          </table>
31776          <details class="schema"><summary>Response schema</summary>
31777<div class="schema-block">{
31778  "found": string | null,  // absolute path to the coverage file, if detected
31779  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31780  "hint":  string | null   // shell command to generate coverage if not found
31781}</div></details>
31782          <p class="curl-heading">Example</p>
31783          <div class="curl-wrap">
31784            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31785  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31786            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31787          </div>
31788        </div>
31789      </div>
31790    </div>
31791
31792  </div>
31793
31794  <footer class="site-footer">
31795    local code analysis - metrics, history and reports
31796    &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>
31797    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31798    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31799    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31800    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
31801  </footer>
31802
31803  <script nonce="{{ csp_nonce }}">
31804    (function () {
31805      var base = window.location.origin;
31806      document.getElementById('base-url').textContent = base;
31807      document.querySelectorAll('.base-url-slot').forEach(function (el) {
31808        el.textContent = base;
31809      });
31810
31811      document.querySelectorAll('.ep-header').forEach(function (hdr) {
31812        hdr.addEventListener('click', function () {
31813          hdr.closest('.ep-card').classList.toggle('open');
31814        });
31815      });
31816
31817      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31818        btn.addEventListener('click', function () {
31819          var targetId = btn.dataset.target;
31820          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31821          if (!pre) return;
31822          navigator.clipboard.writeText(pre.textContent).then(function () {
31823            btn.textContent = 'Copied!';
31824            btn.classList.add('copied');
31825            setTimeout(function () {
31826              btn.textContent = 'Copy';
31827              btn.classList.remove('copied');
31828            }, 2000);
31829          });
31830        });
31831      });
31832
31833      var storageKey = 'oxide-sloc-theme';
31834      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31835      var themeBtn = document.getElementById('theme-toggle');
31836      if (themeBtn) {
31837        themeBtn.addEventListener('click', function () {
31838          var dark = document.body.classList.toggle('dark-theme');
31839          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31840        });
31841      }
31842      (function() {
31843        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'}];
31844        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);});}
31845        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31846        var btn=document.getElementById('settings-btn');if(!btn)return;
31847        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31848        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>';
31849        document.body.appendChild(m);
31850        var g=document.getElementById('scheme-grid');
31851        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);});
31852        var cl=document.getElementById('settings-close');
31853        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);
31854        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');});
31855        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31856        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31857      })();
31858      (function randomizeWatermarks() {
31859        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31860        if (!wms.length) return;
31861        var placed = [];
31862        function tooClose(top, left) {
31863          for (var i = 0; i < placed.length; i++) {
31864            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31865            if (dt < 16 && dl < 12) return true;
31866          }
31867          return false;
31868        }
31869        function pick(leftBand) {
31870          for (var attempt = 0; attempt < 50; attempt++) {
31871            var top = Math.random() * 88 + 2;
31872            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31873            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31874          }
31875          var top = Math.random() * 88 + 2;
31876          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31877          placed.push([top, left]); return [top, left];
31878        }
31879        var half = Math.floor(wms.length / 2);
31880        wms.forEach(function (img, i) {
31881          var pos = pick(i < half);
31882          var size = Math.floor(Math.random() * 100 + 120);
31883          var rot = (Math.random() * 360).toFixed(1);
31884          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31885          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;
31886        });
31887      })();
31888      (function spawnCodeParticles() {
31889        var container = document.getElementById('code-particles');
31890        if (!container) return;
31891        var snippets = [
31892          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31893          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31894          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31895          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31896          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31897        ];
31898        var count = 38;
31899        for (var i = 0; i < count; i++) {
31900          (function(idx) {
31901            var el = document.createElement('span');
31902            el.className = 'code-particle';
31903            el.textContent = snippets[idx % snippets.length];
31904            var left = Math.random() * 94 + 2;
31905            var top = Math.random() * 88 + 6;
31906            var dur = (Math.random() * 10 + 9).toFixed(1);
31907            var delay = (Math.random() * 18).toFixed(1);
31908            var rot = (Math.random() * 26 - 13).toFixed(1);
31909            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31910            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31911            container.appendChild(el);
31912          })(i);
31913        }
31914      })();
31915    }());
31916  </script>
31917</body>
31918</html>
31919"##,
31920    ext = "html"
31921)]
31922struct ApiDocsTemplate {
31923    has_api_key: bool,
31924    csp_nonce: String,
31925    version: &'static str,
31926}
31927
31928#[cfg(test)]
31929mod form_config_tests {
31930    use super::*;
31931    use sloc_config::{
31932        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31933    };
31934
31935    fn blank_form() -> AnalyzeForm {
31936        AnalyzeForm {
31937            path: ".".to_string(),
31938            git_repo: None,
31939            git_ref: None,
31940            mixed_line_policy: None,
31941            python_docstrings_as_comments: None,
31942            generated_file_detection: None,
31943            minified_file_detection: None,
31944            vendor_directory_detection: None,
31945            include_lockfiles: None,
31946            binary_file_behavior: None,
31947            output_dir: None,
31948            report_title: None,
31949            report_header_footer: None,
31950            include_globs: None,
31951            exclude_globs: None,
31952            submodule_breakdown: None,
31953            coverage_file: None,
31954            continuation_line_policy: None,
31955            blank_in_block_comment_policy: None,
31956            count_compiler_directives: None,
31957            style_col_threshold: None,
31958            style_analysis_enabled: None,
31959            style_score_threshold: None,
31960            style_lang_scope: None,
31961            cocomo_mode: None,
31962            complexity_alert: None,
31963            exclude_duplicates: None,
31964            activity_window: None,
31965        }
31966    }
31967
31968    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31969        let mut cfg = sloc_config::AppConfig::default();
31970        apply_form_to_config(&mut cfg, form);
31971        cfg
31972    }
31973
31974    // ── activity_window (git hotspots — on by default) ──
31975
31976    #[test]
31977    fn extract_long_commit_picks_super_repo_by_short_prefix() {
31978        // A pretty-printed JSON tail containing several submodule git_commit_long
31979        // values plus the super-repo's; the helper must return the one whose hash
31980        // starts with the known short SHA, ignoring the others and any null value.
31981        let dir = tempfile::tempdir().unwrap();
31982        let path = dir.path().join("result.json");
31983        let body = r#"{
31984  "submodules": [
31985    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31986    { "git_commit_long": null }
31987  ],
31988  "git_commit_short": "4c2cd9b",
31989  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31990}"#;
31991        std::fs::write(&path, body).unwrap();
31992        assert_eq!(
31993            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31994            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31995        );
31996        // No match for an unrelated short SHA, and empty short yields None.
31997        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31998        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31999    }
32000
32001    #[test]
32002    fn activity_window_defaults_on_when_field_blank() {
32003        // Blank form field keeps the config default (90 days).
32004        let cfg = apply(&blank_form());
32005        assert_eq!(cfg.analysis.activity_window_days, Some(90));
32006    }
32007
32008    #[test]
32009    fn activity_window_override_sets_days() {
32010        let mut form = blank_form();
32011        form.activity_window = Some("30".to_string());
32012        let cfg = apply(&form);
32013        assert_eq!(cfg.analysis.activity_window_days, Some(30));
32014    }
32015
32016    #[test]
32017    fn activity_window_zero_disables() {
32018        // An explicit 0 from the form disables hotspots (overrides the default-on).
32019        let mut form = blank_form();
32020        form.activity_window = Some("0".to_string());
32021        let cfg = apply(&form);
32022        assert_eq!(cfg.analysis.activity_window_days, Some(0));
32023    }
32024
32025    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
32026
32027    #[test]
32028    fn python_docstrings_false_when_unchecked() {
32029        // Checkbox absent in form data (unchecked) → field must be false.
32030        let cfg = apply(&blank_form());
32031        assert!(
32032            !cfg.analysis.python_docstrings_as_comments,
32033            "absent python_docstrings_as_comments must map to false"
32034        );
32035    }
32036
32037    #[test]
32038    fn python_docstrings_true_when_checked() {
32039        // Browser sends "on" (no value= attr on the checkbox).
32040        let mut form = blank_form();
32041        form.python_docstrings_as_comments = Some("on".to_string());
32042        let cfg = apply(&form);
32043        assert!(cfg.analysis.python_docstrings_as_comments);
32044    }
32045
32046    #[test]
32047    fn python_docstrings_true_for_any_non_none_value() {
32048        // The handler uses .is_some() — any non-None value means "checked".
32049        let mut form = blank_form();
32050        form.python_docstrings_as_comments = Some("true".to_string());
32051        assert!(apply(&form).analysis.python_docstrings_as_comments);
32052    }
32053
32054    // ── submodule_breakdown (checkbox with value="enabled") ──
32055
32056    #[test]
32057    fn submodule_breakdown_false_when_unchecked() {
32058        let cfg = apply(&blank_form());
32059        assert!(
32060            !cfg.discovery.submodule_breakdown,
32061            "absent submodule_breakdown must map to false"
32062        );
32063    }
32064
32065    #[test]
32066    fn submodule_breakdown_true_when_value_enabled() {
32067        let mut form = blank_form();
32068        form.submodule_breakdown = Some("enabled".to_string());
32069        assert!(apply(&form).discovery.submodule_breakdown);
32070    }
32071
32072    #[test]
32073    fn submodule_breakdown_false_for_wrong_value() {
32074        // If somehow a value other than "enabled" is sent, it must still be false.
32075        let mut form = blank_form();
32076        form.submodule_breakdown = Some("on".to_string());
32077        assert!(
32078            !apply(&form).discovery.submodule_breakdown,
32079            "submodule_breakdown only becomes true for the exact value 'enabled'"
32080        );
32081    }
32082
32083    // ── generated_file_detection (select: "enabled" | "disabled") ──
32084
32085    #[test]
32086    fn generated_detection_true_when_enabled() {
32087        let mut form = blank_form();
32088        form.generated_file_detection = Some("enabled".to_string());
32089        assert!(apply(&form).analysis.generated_file_detection);
32090    }
32091
32092    #[test]
32093    fn generated_detection_false_when_disabled() {
32094        let mut form = blank_form();
32095        form.generated_file_detection = Some("disabled".to_string());
32096        assert!(!apply(&form).analysis.generated_file_detection);
32097    }
32098
32099    #[test]
32100    fn generated_detection_true_when_absent() {
32101        // None != Some("disabled") → true (safe default)
32102        assert!(
32103            apply(&blank_form()).analysis.generated_file_detection,
32104            "absent field must default to true (detection on)"
32105        );
32106    }
32107
32108    // ── minified_file_detection ──
32109
32110    #[test]
32111    fn minified_detection_false_when_disabled() {
32112        let mut form = blank_form();
32113        form.minified_file_detection = Some("disabled".to_string());
32114        assert!(!apply(&form).analysis.minified_file_detection);
32115    }
32116
32117    #[test]
32118    fn minified_detection_true_when_enabled() {
32119        let mut form = blank_form();
32120        form.minified_file_detection = Some("enabled".to_string());
32121        assert!(apply(&form).analysis.minified_file_detection);
32122    }
32123
32124    #[test]
32125    fn minified_detection_true_when_absent() {
32126        assert!(apply(&blank_form()).analysis.minified_file_detection);
32127    }
32128
32129    // ── vendor_directory_detection ──
32130
32131    #[test]
32132    fn vendor_detection_false_when_disabled() {
32133        let mut form = blank_form();
32134        form.vendor_directory_detection = Some("disabled".to_string());
32135        assert!(!apply(&form).analysis.vendor_directory_detection);
32136    }
32137
32138    #[test]
32139    fn vendor_detection_true_when_enabled() {
32140        let mut form = blank_form();
32141        form.vendor_directory_detection = Some("enabled".to_string());
32142        assert!(apply(&form).analysis.vendor_directory_detection);
32143    }
32144
32145    #[test]
32146    fn vendor_detection_true_when_absent() {
32147        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32148    }
32149
32150    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32151
32152    #[test]
32153    fn lockfiles_false_when_absent() {
32154        // None == Some("enabled") is false → lockfiles off (correct safe default)
32155        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32156    }
32157
32158    #[test]
32159    fn lockfiles_false_when_disabled() {
32160        let mut form = blank_form();
32161        form.include_lockfiles = Some("disabled".to_string());
32162        assert!(!apply(&form).analysis.include_lockfiles);
32163    }
32164
32165    #[test]
32166    fn lockfiles_true_when_enabled() {
32167        let mut form = blank_form();
32168        form.include_lockfiles = Some("enabled".to_string());
32169        assert!(apply(&form).analysis.include_lockfiles);
32170    }
32171
32172    // ── count_compiler_directives ──
32173
32174    #[test]
32175    fn compiler_directives_true_when_absent() {
32176        assert!(
32177            apply(&blank_form()).analysis.count_compiler_directives,
32178            "absent count_compiler_directives must default to true"
32179        );
32180    }
32181
32182    #[test]
32183    fn compiler_directives_true_when_enabled() {
32184        let mut form = blank_form();
32185        form.count_compiler_directives = Some("enabled".to_string());
32186        assert!(apply(&form).analysis.count_compiler_directives);
32187    }
32188
32189    #[test]
32190    fn compiler_directives_false_when_disabled() {
32191        let mut form = blank_form();
32192        form.count_compiler_directives = Some("disabled".to_string());
32193        assert!(!apply(&form).analysis.count_compiler_directives);
32194    }
32195
32196    // ── mixed_line_policy (enum select) ──
32197
32198    #[test]
32199    fn mixed_policy_unchanged_when_absent() {
32200        // None → if-let does nothing → stays at config default (CodeOnly)
32201        assert_eq!(
32202            apply(&blank_form()).analysis.mixed_line_policy,
32203            MixedLinePolicy::CodeOnly
32204        );
32205    }
32206
32207    #[test]
32208    fn mixed_policy_code_only() {
32209        let mut form = blank_form();
32210        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32211        assert_eq!(
32212            apply(&form).analysis.mixed_line_policy,
32213            MixedLinePolicy::CodeOnly
32214        );
32215    }
32216
32217    #[test]
32218    fn mixed_policy_code_and_comment() {
32219        let mut form = blank_form();
32220        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32221        assert_eq!(
32222            apply(&form).analysis.mixed_line_policy,
32223            MixedLinePolicy::CodeAndComment
32224        );
32225    }
32226
32227    #[test]
32228    fn mixed_policy_comment_only() {
32229        let mut form = blank_form();
32230        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32231        assert_eq!(
32232            apply(&form).analysis.mixed_line_policy,
32233            MixedLinePolicy::CommentOnly
32234        );
32235    }
32236
32237    #[test]
32238    fn mixed_policy_separate_mixed_category() {
32239        let mut form = blank_form();
32240        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32241        assert_eq!(
32242            apply(&form).analysis.mixed_line_policy,
32243            MixedLinePolicy::SeparateMixedCategory
32244        );
32245    }
32246
32247    // ── binary_file_behavior (enum select) ──
32248
32249    #[test]
32250    fn binary_behavior_skip_when_absent() {
32251        assert_eq!(
32252            apply(&blank_form()).analysis.binary_file_behavior,
32253            BinaryFileBehavior::Skip
32254        );
32255    }
32256
32257    #[test]
32258    fn binary_behavior_skip() {
32259        let mut form = blank_form();
32260        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32261        assert_eq!(
32262            apply(&form).analysis.binary_file_behavior,
32263            BinaryFileBehavior::Skip
32264        );
32265    }
32266
32267    #[test]
32268    fn binary_behavior_fail() {
32269        let mut form = blank_form();
32270        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32271        assert_eq!(
32272            apply(&form).analysis.binary_file_behavior,
32273            BinaryFileBehavior::Fail
32274        );
32275    }
32276
32277    // ── continuation_line_policy (enum select) ──
32278
32279    #[test]
32280    fn continuation_policy_each_physical_when_absent() {
32281        assert_eq!(
32282            apply(&blank_form()).analysis.continuation_line_policy,
32283            ContinuationLinePolicy::EachPhysicalLine
32284        );
32285    }
32286
32287    #[test]
32288    fn continuation_policy_collapse_to_logical() {
32289        let mut form = blank_form();
32290        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32291        assert_eq!(
32292            apply(&form).analysis.continuation_line_policy,
32293            ContinuationLinePolicy::CollapseToLogical
32294        );
32295    }
32296
32297    // ── blank_in_block_comment_policy (enum select) ──
32298
32299    #[test]
32300    fn blank_in_block_comment_count_as_comment_when_absent() {
32301        assert_eq!(
32302            apply(&blank_form()).analysis.blank_in_block_comment_policy,
32303            BlankInBlockCommentPolicy::CountAsComment
32304        );
32305    }
32306
32307    #[test]
32308    fn blank_in_block_comment_count_as_blank() {
32309        let mut form = blank_form();
32310        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32311        assert_eq!(
32312            apply(&form).analysis.blank_in_block_comment_policy,
32313            BlankInBlockCommentPolicy::CountAsBlank
32314        );
32315    }
32316
32317    // ── style_col_threshold ──
32318
32319    #[test]
32320    fn style_threshold_80() {
32321        let mut form = blank_form();
32322        form.style_col_threshold = Some("80".to_string());
32323        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32324    }
32325
32326    #[test]
32327    fn style_threshold_100() {
32328        let mut form = blank_form();
32329        form.style_col_threshold = Some("100".to_string());
32330        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32331    }
32332
32333    #[test]
32334    fn style_threshold_120() {
32335        let mut form = blank_form();
32336        form.style_col_threshold = Some("120".to_string());
32337        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32338    }
32339
32340    #[test]
32341    fn style_threshold_invalid_value_leaves_default() {
32342        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
32343        let mut cfg = sloc_config::AppConfig::default();
32344        let mut form = blank_form();
32345        form.style_col_threshold = Some("42".to_string());
32346        apply_form_to_config(&mut cfg, &form);
32347        assert_eq!(
32348            cfg.analysis.style_col_threshold, 80,
32349            "invalid threshold must not change config"
32350        );
32351    }
32352
32353    #[test]
32354    fn style_threshold_non_numeric_leaves_default() {
32355        let mut cfg = sloc_config::AppConfig::default();
32356        let mut form = blank_form();
32357        form.style_col_threshold = Some("large".to_string());
32358        apply_form_to_config(&mut cfg, &form);
32359        assert_eq!(cfg.analysis.style_col_threshold, 80);
32360    }
32361
32362    #[test]
32363    fn style_threshold_zero_leaves_default() {
32364        let mut cfg = sloc_config::AppConfig::default();
32365        let mut form = blank_form();
32366        form.style_col_threshold = Some("0".to_string());
32367        apply_form_to_config(&mut cfg, &form);
32368        assert_eq!(cfg.analysis.style_col_threshold, 80);
32369    }
32370
32371    #[test]
32372    fn style_threshold_absent_leaves_default() {
32373        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32374    }
32375
32376    // ── style_score_threshold ──
32377
32378    #[test]
32379    fn style_score_threshold_zero_when_absent() {
32380        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32381    }
32382
32383    #[test]
32384    fn style_score_threshold_set_to_valid_value() {
32385        let mut form = blank_form();
32386        form.style_score_threshold = Some("70".to_string());
32387        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32388    }
32389
32390    #[test]
32391    fn style_score_threshold_clamps_to_100_when_over() {
32392        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
32393        let mut form = blank_form();
32394        form.style_score_threshold = Some("200".to_string());
32395        assert_eq!(
32396            apply(&form).analysis.style_score_threshold,
32397            100,
32398            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32399        );
32400    }
32401
32402    // ── coverage_file ──
32403
32404    #[test]
32405    fn coverage_file_none_when_absent() {
32406        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32407    }
32408
32409    #[test]
32410    fn coverage_file_none_when_whitespace_only() {
32411        let mut form = blank_form();
32412        form.coverage_file = Some("   ".to_string());
32413        assert!(
32414            apply(&form).analysis.coverage_file.is_none(),
32415            "whitespace-only coverage_file must be treated as None"
32416        );
32417    }
32418
32419    #[test]
32420    fn coverage_file_set_when_non_empty() {
32421        let mut form = blank_form();
32422        form.coverage_file = Some("coverage/lcov.info".to_string());
32423        assert_eq!(
32424            apply(&form).analysis.coverage_file,
32425            Some(std::path::PathBuf::from("coverage/lcov.info"))
32426        );
32427    }
32428
32429    #[test]
32430    fn coverage_file_trims_whitespace() {
32431        let mut form = blank_form();
32432        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
32433        assert_eq!(
32434            apply(&form).analysis.coverage_file,
32435            Some(std::path::PathBuf::from("coverage/lcov.info"))
32436        );
32437    }
32438
32439    // ── report_title ──
32440
32441    #[test]
32442    fn report_title_unchanged_when_absent() {
32443        let original = sloc_config::AppConfig::default().reporting.report_title;
32444        assert_eq!(apply(&blank_form()).reporting.report_title, original);
32445    }
32446
32447    #[test]
32448    fn report_title_unchanged_when_whitespace_only() {
32449        let original = sloc_config::AppConfig::default().reporting.report_title;
32450        let mut form = blank_form();
32451        form.report_title = Some("   ".to_string());
32452        assert_eq!(
32453            apply(&form).reporting.report_title,
32454            original,
32455            "whitespace-only title must not overwrite the default"
32456        );
32457    }
32458
32459    #[test]
32460    fn report_title_updated_and_trimmed() {
32461        let mut form = blank_form();
32462        form.report_title = Some("  My Project  ".to_string());
32463        assert_eq!(apply(&form).reporting.report_title, "My Project");
32464    }
32465
32466    // ── report_header_footer ──
32467
32468    #[test]
32469    fn header_footer_none_when_absent() {
32470        assert!(apply(&blank_form())
32471            .reporting
32472            .report_header_footer
32473            .is_none());
32474    }
32475
32476    #[test]
32477    fn header_footer_none_when_whitespace_only() {
32478        let mut form = blank_form();
32479        form.report_header_footer = Some("  ".to_string());
32480        assert!(apply(&form).reporting.report_header_footer.is_none());
32481    }
32482
32483    #[test]
32484    fn header_footer_set_and_trimmed() {
32485        let mut form = blank_form();
32486        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
32487        assert_eq!(
32488            apply(&form).reporting.report_header_footer,
32489            Some("Confidential — Internal Use".to_string())
32490        );
32491    }
32492
32493    // ── include_globs / exclude_globs ──
32494
32495    #[test]
32496    fn include_globs_empty_when_absent() {
32497        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32498    }
32499
32500    #[test]
32501    fn include_globs_newline_separated() {
32502        let mut form = blank_form();
32503        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32504        assert_eq!(
32505            apply(&form).discovery.include_globs,
32506            vec!["src/**/*.rs", "tests/**/*.rs"]
32507        );
32508    }
32509
32510    #[test]
32511    fn exclude_globs_comma_separated() {
32512        let mut form = blank_form();
32513        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32514        assert_eq!(
32515            apply(&form).discovery.exclude_globs,
32516            vec!["vendor/**", "node_modules/**"]
32517        );
32518    }
32519
32520    #[test]
32521    fn globs_mixed_separators() {
32522        let mut form = blank_form();
32523        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32524        assert_eq!(
32525            apply(&form).discovery.exclude_globs,
32526            vec!["a/**", "b/**", "c/**"]
32527        );
32528    }
32529
32530    // ── split_patterns unit tests ──
32531
32532    #[test]
32533    fn split_patterns_none_is_empty() {
32534        assert!(split_patterns(None).is_empty());
32535    }
32536
32537    #[test]
32538    fn split_patterns_empty_string_is_empty() {
32539        assert!(split_patterns(Some("")).is_empty());
32540    }
32541
32542    #[test]
32543    fn split_patterns_whitespace_only_is_empty() {
32544        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
32545    }
32546
32547    #[test]
32548    fn split_patterns_newlines() {
32549        assert_eq!(
32550            split_patterns(Some("a/**\nb/**\nc/**")),
32551            vec!["a/**", "b/**", "c/**"]
32552        );
32553    }
32554
32555    #[test]
32556    fn split_patterns_commas() {
32557        assert_eq!(
32558            split_patterns(Some("a/**,b/**,c/**")),
32559            vec!["a/**", "b/**", "c/**"]
32560        );
32561    }
32562
32563    #[test]
32564    fn split_patterns_mixed() {
32565        assert_eq!(
32566            split_patterns(Some("a/**\nb/**,c/**")),
32567            vec!["a/**", "b/**", "c/**"]
32568        );
32569    }
32570
32571    #[test]
32572    fn split_patterns_trims_whitespace() {
32573        assert_eq!(
32574            split_patterns(Some("  a/**  \n  b/**  ")),
32575            vec!["a/**", "b/**"]
32576        );
32577    }
32578
32579    #[test]
32580    fn split_patterns_filters_empty_entries() {
32581        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32582    }
32583
32584    #[test]
32585    fn split_patterns_single_entry() {
32586        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32587    }
32588}
32589
32590#[cfg(test)]
32591mod utility_tests {
32592    use super::*;
32593    use std::net::IpAddr;
32594    use std::time::Duration;
32595
32596    // ── sanitize_project_label ────────────────────────────────────────────────
32597
32598    #[test]
32599    fn sanitize_simple_name() {
32600        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32601    }
32602
32603    #[test]
32604    fn sanitize_uppercased_lowercased() {
32605        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32606    }
32607
32608    #[test]
32609    fn sanitize_path_extracts_filename() {
32610        assert_eq!(
32611            sanitize_project_label("/home/user/my-project"),
32612            "my-project"
32613        );
32614    }
32615
32616    #[test]
32617    fn sanitize_path_uses_last_component() {
32618        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32619    }
32620
32621    #[test]
32622    fn sanitize_spaces_become_hyphens() {
32623        assert_eq!(sanitize_project_label("my project"), "my-project");
32624    }
32625
32626    #[test]
32627    fn sanitize_non_ascii_become_hyphens() {
32628        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32629    }
32630
32631    #[test]
32632    fn sanitize_all_special_chars_gives_project() {
32633        assert_eq!(sanitize_project_label("!@#$%^"), "project");
32634    }
32635
32636    #[test]
32637    fn sanitize_empty_string_gives_project() {
32638        assert_eq!(sanitize_project_label(""), "project");
32639    }
32640
32641    #[test]
32642    fn sanitize_leading_trailing_hyphens_stripped() {
32643        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32644    }
32645
32646    #[test]
32647    fn sanitize_alphanumeric_preserved() {
32648        assert_eq!(sanitize_project_label("repo123"), "repo123");
32649    }
32650
32651    #[test]
32652    fn sanitize_dots_become_hyphens() {
32653        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32654    }
32655
32656    #[test]
32657    fn sanitize_mixed_slashes_uses_filename() {
32658        // The Windows path separator — on all platforms Path::file_name still works
32659        assert_eq!(sanitize_project_label("project-name"), "project-name");
32660    }
32661
32662    // ── IpRateLimiter ─────────────────────────────────────────────────────────
32663
32664    #[test]
32665    fn rate_limiter_allows_first_request() {
32666        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32667        let ip: IpAddr = "127.0.0.1".parse().unwrap();
32668        assert!(rl.is_allowed(ip));
32669    }
32670
32671    #[test]
32672    fn rate_limiter_blocks_after_limit_reached() {
32673        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32674        let ip: IpAddr = "10.0.0.1".parse().unwrap();
32675        assert!(rl.is_allowed(ip));
32676        assert!(rl.is_allowed(ip));
32677        assert!(rl.is_allowed(ip));
32678        assert!(!rl.is_allowed(ip), "4th request must be blocked");
32679    }
32680
32681    #[test]
32682    fn rate_limiter_allows_requests_up_to_limit() {
32683        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32684        let ip: IpAddr = "10.0.0.2".parse().unwrap();
32685        for _ in 0..5 {
32686            assert!(rl.is_allowed(ip));
32687        }
32688        assert!(!rl.is_allowed(ip), "6th request must be blocked");
32689    }
32690
32691    #[test]
32692    fn rate_limiter_different_ips_are_independent() {
32693        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32694        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32695        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32696        assert!(rl.is_allowed(ip1));
32697        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32698        assert!(rl.is_allowed(ip2), "ip2 must be independent");
32699    }
32700
32701    #[test]
32702    fn rate_limiter_auth_failure_not_locked_below_threshold() {
32703        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32704        let ip: IpAddr = "10.0.0.3".parse().unwrap();
32705        rl.record_auth_failure(ip);
32706        rl.record_auth_failure(ip);
32707        assert!(
32708            !rl.is_auth_locked_out(ip),
32709            "not locked at 2 failures when threshold is 3"
32710        );
32711    }
32712
32713    #[test]
32714    fn rate_limiter_auth_failure_locked_at_threshold() {
32715        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32716        let ip: IpAddr = "10.0.0.4".parse().unwrap();
32717        rl.record_auth_failure(ip);
32718        rl.record_auth_failure(ip);
32719        rl.record_auth_failure(ip);
32720        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32721    }
32722
32723    #[test]
32724    fn rate_limiter_auth_failure_different_ips_independent() {
32725        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32726        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32727        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32728        rl.record_auth_failure(ip1);
32729        rl.record_auth_failure(ip1);
32730        assert!(rl.is_auth_locked_out(ip1));
32731        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32732    }
32733
32734    #[test]
32735    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32736        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32737        let ip: IpAddr = "127.0.0.2".parse().unwrap();
32738        for _ in 0..100 {
32739            assert!(rl.is_allowed(ip));
32740        }
32741    }
32742
32743    // ── strip_unc_prefix ──────────────────────────────────────────────────────
32744
32745    #[test]
32746    fn strip_unc_plain_path_unchanged() {
32747        let p = PathBuf::from("C:\\Users\\user\\project");
32748        let result = strip_unc_prefix(p.clone());
32749        assert_eq!(result, p);
32750    }
32751
32752    #[test]
32753    fn strip_unc_with_drive_prefix_stripped() {
32754        let p = PathBuf::from(r"\\?\C:\Users\user\project");
32755        let result = strip_unc_prefix(p);
32756        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32757    }
32758
32759    #[test]
32760    fn strip_unc_with_network_prefix_stripped() {
32761        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32762        let result = strip_unc_prefix(p);
32763        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32764    }
32765
32766    #[test]
32767    fn strip_unc_linux_path_unchanged() {
32768        let p = PathBuf::from("/home/user/project");
32769        let result = strip_unc_prefix(p.clone());
32770        assert_eq!(result, p);
32771    }
32772
32773    // ── remote_to_commit_url ──────────────────────────────────────────────────
32774
32775    #[test]
32776    fn remote_to_commit_url_github_https() {
32777        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32778        assert_eq!(
32779            url,
32780            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32781        );
32782    }
32783
32784    #[test]
32785    fn remote_to_commit_url_github_ssh() {
32786        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32787        assert_eq!(
32788            url,
32789            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32790        );
32791    }
32792
32793    #[test]
32794    fn remote_to_commit_url_gitlab_uses_dash_commit() {
32795        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32796        assert_eq!(
32797            url,
32798            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32799        );
32800    }
32801
32802    #[test]
32803    fn remote_to_commit_url_bitbucket_uses_commits() {
32804        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32805        assert_eq!(
32806            url,
32807            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32808        );
32809    }
32810
32811    #[test]
32812    fn remote_to_commit_url_unknown_scheme_returns_none() {
32813        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32814        assert!(url.is_none());
32815    }
32816
32817    #[test]
32818    fn remote_to_commit_url_ssh_gitlab() {
32819        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32820        assert!(url.is_some());
32821        let u = url.unwrap();
32822        assert!(
32823            u.contains("/-/commit/sha123"),
32824            "gitlab ssh must use /-/commit/"
32825        );
32826    }
32827
32828    // ── git_clone_dest ────────────────────────────────────────────────────────
32829
32830    #[test]
32831    fn git_clone_dest_github_url_produces_safe_name() {
32832        let dir = PathBuf::from("/tmp/clones");
32833        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32834        let name = dest.file_name().unwrap().to_string_lossy();
32835        assert!(!name.is_empty());
32836        assert!(
32837            name.chars()
32838                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32839            "clone dest must only contain safe chars, got: {name}"
32840        );
32841    }
32842
32843    #[test]
32844    fn git_clone_dest_is_inside_clones_dir() {
32845        let dir = PathBuf::from("/tmp/clones");
32846        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32847        assert!(
32848            dest.starts_with(&dir),
32849            "clone dest must be inside clones_dir"
32850        );
32851    }
32852
32853    #[test]
32854    fn git_clone_dest_truncates_to_80_chars_max() {
32855        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32856        let dir = PathBuf::from("/tmp/clones");
32857        let dest = git_clone_dest(&long_url, &dir);
32858        let name = dest.file_name().unwrap().to_string_lossy();
32859        assert!(
32860            name.len() <= 80,
32861            "clone dest name must be at most 80 chars, got {} chars: {name}",
32862            name.len()
32863        );
32864    }
32865
32866    #[test]
32867    fn git_clone_dest_special_chars_replaced_with_underscore() {
32868        let dir = PathBuf::from("/tmp/clones");
32869        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32870        let name = dest.file_name().unwrap().to_string_lossy();
32871        assert!(
32872            !name.contains('@') && !name.contains(':') && !name.contains('/'),
32873            "special chars must be replaced in clone dest, got: {name}"
32874        );
32875    }
32876
32877    #[test]
32878    fn git_clone_dest_different_urls_differ() {
32879        let dir = PathBuf::from("/tmp/clones");
32880        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32881        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32882        assert_ne!(
32883            a, b,
32884            "different repos must produce different clone dest names"
32885        );
32886    }
32887
32888    #[test]
32889    fn git_clone_dest_same_url_same_result() {
32890        let dir = PathBuf::from("/tmp/clones");
32891        let url = "https://github.com/owner/repo.git";
32892        assert_eq!(
32893            git_clone_dest(url, &dir),
32894            git_clone_dest(url, &dir),
32895            "same URL must always give same clone dest"
32896        );
32897    }
32898
32899    // ── fmt_delta ─────────────────────────────────────────────────────────────
32900
32901    #[test]
32902    fn fmt_delta_positive_has_plus_prefix() {
32903        assert_eq!(fmt_delta(5), "+5");
32904    }
32905
32906    #[test]
32907    fn fmt_delta_negative_no_plus_prefix() {
32908        assert_eq!(fmt_delta(-3), "-3");
32909    }
32910
32911    #[test]
32912    fn fmt_delta_zero() {
32913        assert_eq!(fmt_delta(0), "0");
32914    }
32915
32916    // ── delta_class ───────────────────────────────────────────────────────────
32917
32918    #[test]
32919    fn delta_class_positive_is_pos() {
32920        assert_eq!(delta_class(1), "pos");
32921    }
32922
32923    #[test]
32924    fn delta_class_negative_is_neg() {
32925        assert_eq!(delta_class(-1), "neg");
32926    }
32927
32928    #[test]
32929    fn delta_class_zero_is_zero_class() {
32930        assert_eq!(delta_class(0), "zero");
32931    }
32932
32933    // ── fmt_pct ───────────────────────────────────────────────────────────────
32934
32935    #[test]
32936    fn fmt_pct_zero_baseline_returns_em_dash() {
32937        assert_eq!(fmt_pct(100, 0), "\u{2014}");
32938    }
32939
32940    #[test]
32941    fn fmt_pct_positive_delta_has_plus_sign() {
32942        let result = fmt_pct(10, 100);
32943        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32944    }
32945
32946    #[test]
32947    fn fmt_pct_negative_delta_no_plus_sign() {
32948        let result = fmt_pct(-10, 100);
32949        assert!(!result.starts_with('+'), "unexpected + in: {result}");
32950        assert!(result.contains('%'));
32951    }
32952
32953    #[test]
32954    fn fmt_pct_near_zero_returns_pm_zero() {
32955        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32956    }
32957
32958    // ── summary_delta ─────────────────────────────────────────────────────────
32959
32960    #[test]
32961    fn summary_delta_no_prev_returns_dash_na() {
32962        let (display, class) = summary_delta(10, None);
32963        assert_eq!(display, "\u{2014}");
32964        assert_eq!(class, "na");
32965    }
32966
32967    #[test]
32968    fn summary_delta_increase_is_positive() {
32969        let (display, class) = summary_delta(15, Some(10));
32970        assert_eq!(display, "+5");
32971        assert_eq!(class, "pos");
32972    }
32973
32974    #[test]
32975    fn summary_delta_decrease_is_negative() {
32976        let (display, class) = summary_delta(5, Some(10));
32977        assert_eq!(display, "-5");
32978        assert_eq!(class, "neg");
32979    }
32980
32981    // ── nth_weekday_of_month ──────────────────────────────────────────────────
32982
32983    #[test]
32984    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32985        use chrono::Datelike;
32986        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32987        assert_eq!(d.year(), 2024);
32988        assert_eq!(d.month(), 1);
32989        assert_eq!(d.weekday(), chrono::Weekday::Mon);
32990        assert!(d.day() <= 7);
32991    }
32992
32993    #[test]
32994    fn nth_weekday_second_sunday_march_2024_is_10th() {
32995        use chrono::Datelike;
32996        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32997        assert_eq!(d.weekday(), chrono::Weekday::Sun);
32998        assert_eq!(d.month(), 3);
32999        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33000    }
33001
33002    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
33003
33004    #[test]
33005    fn is_pacific_dst_july_is_true() {
33006        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33007        assert!(is_pacific_dst(dt), "July must be PDT");
33008    }
33009
33010    #[test]
33011    fn is_pacific_dst_january_is_false() {
33012        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33013        assert!(!is_pacific_dst(dt), "January must be PST");
33014    }
33015
33016    #[test]
33017    fn fmt_la_time_summer_shows_pdt() {
33018        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33019        let result = fmt_la_time(dt);
33020        assert!(
33021            result.ends_with("PDT"),
33022            "summer must use PDT, got: {result}"
33023        );
33024    }
33025
33026    #[test]
33027    fn fmt_la_time_winter_shows_pst() {
33028        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33029        let result = fmt_la_time(dt);
33030        assert!(
33031            result.ends_with("PST"),
33032            "winter must use PST, got: {result}"
33033        );
33034    }
33035
33036    #[test]
33037    fn fmt_la_time_meta_summer_shows_pdt() {
33038        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33039        let result = fmt_la_time_meta(dt);
33040        assert!(
33041            result.ends_with("PDT"),
33042            "meta summer must use PDT, got: {result}"
33043        );
33044    }
33045
33046    #[test]
33047    fn fmt_la_time_meta_winter_shows_pst() {
33048        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33049        let result = fmt_la_time_meta(dt);
33050        assert!(
33051            result.ends_with("PST"),
33052            "meta winter must use PST, got: {result}"
33053        );
33054    }
33055
33056    // ── fmt_git_date ──────────────────────────────────────────────────────────
33057
33058    #[test]
33059    fn fmt_git_date_valid_iso_returns_some() {
33060        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33061    }
33062
33063    #[test]
33064    fn fmt_git_date_invalid_returns_none() {
33065        assert!(fmt_git_date("not-a-date").is_none());
33066    }
33067
33068    // ── format_number ─────────────────────────────────────────────────────────
33069
33070    #[test]
33071    fn format_number_zero() {
33072        assert_eq!(format_number(0), "0");
33073    }
33074
33075    #[test]
33076    fn format_number_three_digits_no_comma() {
33077        assert_eq!(format_number(999), "999");
33078    }
33079
33080    #[test]
33081    fn format_number_four_digits_has_comma() {
33082        assert_eq!(format_number(1000), "1,000");
33083    }
33084
33085    #[test]
33086    fn format_number_seven_digits_two_commas() {
33087        assert_eq!(format_number(1_234_567), "1,234,567");
33088    }
33089
33090    #[test]
33091    fn format_number_one_million() {
33092        assert_eq!(format_number(1_000_000), "1,000,000");
33093    }
33094
33095    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
33096
33097    #[test]
33098    fn badge_text_px_empty_is_zero() {
33099        assert_eq!(badge_text_px(""), 0);
33100    }
33101
33102    #[test]
33103    fn badge_text_px_narrow_chars_smaller_than_normal() {
33104        assert!(
33105            badge_text_px("if") < badge_text_px("ab"),
33106            "'if' must be narrower than 'ab'"
33107        );
33108    }
33109
33110    #[test]
33111    fn badge_text_px_m_is_wider_than_a() {
33112        assert!(
33113            badge_text_px("m") > badge_text_px("a"),
33114            "'m' must be wider than 'a'"
33115        );
33116    }
33117
33118    #[test]
33119    fn render_badge_svg_contains_label_and_value() {
33120        let svg = render_badge_svg("coverage", "95%", "#4c1");
33121        assert!(svg.contains("coverage") && svg.contains("95%"));
33122    }
33123
33124    #[test]
33125    fn render_badge_svg_contains_color() {
33126        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33127        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33128    }
33129
33130    #[test]
33131    fn render_badge_svg_escapes_ampersand_in_label() {
33132        let svg = render_badge_svg("test&label", "ok", "#4c1");
33133        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33134    }
33135
33136    // ── build_pdf_filename ────────────────────────────────────────────────────
33137
33138    #[test]
33139    fn build_pdf_filename_slugifies_title() {
33140        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33141        assert!(
33142            name.starts_with("my_project_report_")
33143                && std::path::Path::new(&name)
33144                    .extension()
33145                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33146        );
33147    }
33148
33149    #[test]
33150    fn build_pdf_filename_uses_last_run_id_segment() {
33151        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33152        assert!(name.contains("ABCD"), "must use last segment of run_id");
33153    }
33154
33155    #[test]
33156    fn build_pdf_filename_empty_title_uses_report_prefix() {
33157        let name = build_pdf_filename("", "abc-def-9999");
33158        assert!(
33159            name.starts_with("report_")
33160                && std::path::Path::new(&name)
33161                    .extension()
33162                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33163        );
33164    }
33165
33166    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33167
33168    #[test]
33169    fn swap_chart_js_replaces_inline_block() {
33170        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33171        let result = swap_inline_chart_js_for_static(html.to_string());
33172        assert!(result.contains(r#"src="/static/chart-report.js""#));
33173        assert!(!result.contains("inline source"));
33174    }
33175
33176    #[test]
33177    fn swap_chart_js_no_head_returns_unchanged() {
33178        let html = "<body>no head here</body>";
33179        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33180    }
33181
33182    #[test]
33183    fn swap_chart_js_no_script_in_head_unchanged() {
33184        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33185        let result = swap_inline_chart_js_for_static(html.to_string());
33186        assert!(!result.contains("chart-report.js"));
33187    }
33188
33189    // ── patch_html_nonce ──────────────────────────────────────────────────────
33190
33191    #[test]
33192    fn patch_html_nonce_replaces_old_nonce() {
33193        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33194        let result = patch_html_nonce(html, "new-nonce-456");
33195        assert!(result.contains(r#"nonce="new-nonce-456""#));
33196        assert!(!result.contains("old-nonce-123"));
33197    }
33198
33199    #[test]
33200    fn patch_html_nonce_injects_into_bare_style() {
33201        let html = "<style>body{color:red;}</style>";
33202        let result = patch_html_nonce(html, "fresh-nonce");
33203        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33204    }
33205
33206    #[test]
33207    fn patch_html_nonce_injects_into_bare_script() {
33208        let html = "<script>console.log(1);</script>";
33209        let result = patch_html_nonce(html, "abc");
33210        assert!(result.contains(r#"<script nonce="abc">"#));
33211    }
33212
33213    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33214
33215    #[test]
33216    fn is_html_report_file_result_html_matches() {
33217        let dir = tempfile::tempdir().unwrap();
33218        let path = dir.path().join("result_20240101.html");
33219        std::fs::write(&path, b"<html></html>").unwrap();
33220        assert!(is_html_report_file(&path));
33221    }
33222
33223    #[test]
33224    fn is_html_report_file_report_html_matches() {
33225        let dir = tempfile::tempdir().unwrap();
33226        let path = dir.path().join("report_abc.html");
33227        std::fs::write(&path, b"<html></html>").unwrap();
33228        assert!(is_html_report_file(&path));
33229    }
33230
33231    #[test]
33232    fn is_html_report_file_index_html_does_not_match() {
33233        let dir = tempfile::tempdir().unwrap();
33234        let path = dir.path().join("index.html");
33235        std::fs::write(&path, b"<html></html>").unwrap();
33236        assert!(!is_html_report_file(&path));
33237    }
33238
33239    #[test]
33240    fn is_html_report_file_nonexistent_returns_false() {
33241        assert!(!is_html_report_file(Path::new(
33242            "/nonexistent/result_xyz.html"
33243        )));
33244    }
33245
33246    #[test]
33247    fn find_html_report_in_dir_finds_result_html() {
33248        let dir = tempfile::tempdir().unwrap();
33249        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33250        assert!(find_html_report_in_dir(dir.path()).is_some());
33251    }
33252
33253    #[test]
33254    fn find_html_report_in_dir_empty_returns_none() {
33255        let dir = tempfile::tempdir().unwrap();
33256        assert!(find_html_report_in_dir(dir.path()).is_none());
33257    }
33258
33259    #[test]
33260    fn find_html_report_in_tree_finds_in_subdir() {
33261        let dir = tempfile::tempdir().unwrap();
33262        let subdir = dir.path().join("run-001");
33263        std::fs::create_dir_all(&subdir).unwrap();
33264        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33265        assert!(find_html_report_in_tree(dir.path()).is_some());
33266    }
33267
33268    // ── derive_project_label ──────────────────────────────────────────────────
33269
33270    #[test]
33271    fn derive_project_label_with_git_repo_and_ref() {
33272        let label = derive_project_label(
33273            Some("https://github.com/owner/my-repo.git"),
33274            Some("main"),
33275            "/fallback/path",
33276        );
33277        assert!(!label.is_empty(), "label must not be empty");
33278        assert!(
33279            label.contains("my") || label.contains("repo"),
33280            "got: {label}"
33281        );
33282    }
33283
33284    #[test]
33285    fn derive_project_label_fallback_to_path() {
33286        let label = derive_project_label(None, None, "/path/to/myproject");
33287        assert_eq!(label, "myproject");
33288    }
33289
33290    #[test]
33291    fn derive_project_label_empty_git_fields_use_path() {
33292        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33293        assert_eq!(label, "cool-app");
33294    }
33295
33296    // ── derive_file_stem ──────────────────────────────────────────────────────
33297
33298    #[test]
33299    fn derive_file_stem_with_commit_appends_sha() {
33300        assert_eq!(
33301            derive_file_stem("myproject", Some("a1b2c3")),
33302            "myproject_a1b2c3"
33303        );
33304    }
33305
33306    #[test]
33307    fn derive_file_stem_without_commit_returns_label() {
33308        assert_eq!(derive_file_stem("myproject", None), "myproject");
33309    }
33310
33311    #[test]
33312    fn derive_file_stem_empty_commit_returns_label() {
33313        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33314    }
33315
33316    // ── split_patterns ────────────────────────────────────────────────────────
33317
33318    #[test]
33319    fn split_patterns_none_is_empty() {
33320        assert!(split_patterns(None).is_empty());
33321    }
33322
33323    #[test]
33324    fn split_patterns_empty_string_is_empty() {
33325        assert!(split_patterns(Some("")).is_empty());
33326    }
33327
33328    #[test]
33329    fn split_patterns_comma_separated() {
33330        assert_eq!(
33331            split_patterns(Some("foo,bar,baz")),
33332            vec!["foo", "bar", "baz"]
33333        );
33334    }
33335
33336    #[test]
33337    fn split_patterns_newline_separated() {
33338        assert_eq!(
33339            split_patterns(Some("foo\nbar\nbaz")),
33340            vec!["foo", "bar", "baz"]
33341        );
33342    }
33343
33344    #[test]
33345    fn split_patterns_trims_whitespace() {
33346        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
33347    }
33348
33349    // ── make_git_label ────────────────────────────────────────────────────────
33350
33351    #[test]
33352    fn make_git_label_empty_repo_empty_result() {
33353        assert_eq!(make_git_label("", "main"), "");
33354    }
33355
33356    #[test]
33357    fn make_git_label_empty_ref_empty_result() {
33358        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33359    }
33360
33361    #[test]
33362    fn make_git_label_basic_format() {
33363        assert_eq!(
33364            make_git_label("https://github.com/owner/my-repo.git", "main"),
33365            "my-repo_at_main_sloc"
33366        );
33367    }
33368
33369    #[test]
33370    fn make_git_label_slash_in_ref_replaced() {
33371        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33372        assert!(
33373            !label.contains('/'),
33374            "slash in ref must be replaced: {label}"
33375        );
33376    }
33377
33378    // ── format_dir_size ───────────────────────────────────────────────────────
33379
33380    #[test]
33381    fn format_dir_size_bytes() {
33382        assert_eq!(format_dir_size(500), "500 B");
33383    }
33384
33385    #[test]
33386    fn format_dir_size_kilobytes() {
33387        assert_eq!(format_dir_size(2048), "2 KB");
33388    }
33389
33390    #[test]
33391    fn format_dir_size_megabytes() {
33392        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33393    }
33394
33395    #[test]
33396    fn format_dir_size_gigabytes() {
33397        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33398    }
33399
33400    #[test]
33401    fn format_dir_size_zero() {
33402        assert_eq!(format_dir_size(0), "0 B");
33403    }
33404
33405    // ── civil_from_days ───────────────────────────────────────────────────────
33406
33407    #[test]
33408    fn civil_from_days_epoch() {
33409        assert_eq!(civil_from_days(0), (1970, 1, 1));
33410    }
33411
33412    #[test]
33413    fn civil_from_days_one_year_later() {
33414        assert_eq!(civil_from_days(365), (1971, 1, 1));
33415    }
33416
33417    #[test]
33418    fn civil_from_days_31_days_is_feb_1_1970() {
33419        assert_eq!(civil_from_days(31), (1970, 2, 1));
33420    }
33421
33422    // ── format_system_time ────────────────────────────────────────────────────
33423
33424    #[test]
33425    fn format_system_time_unix_epoch_formats_correctly() {
33426        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33427    }
33428
33429    #[test]
33430    fn format_system_time_31_days_after_epoch() {
33431        let t = UNIX_EPOCH + Duration::from_hours(744);
33432        assert_eq!(format_system_time(t), "1970-02-01 00:00");
33433    }
33434
33435    #[test]
33436    fn format_system_time_before_epoch_returns_dash() {
33437        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33438            assert_eq!(format_system_time(before), "-");
33439        }
33440    }
33441
33442    // ── detect_language_name ──────────────────────────────────────────────────
33443
33444    #[test]
33445    fn detect_language_name_dot_c() {
33446        assert_eq!(detect_language_name("main.c"), Some("C"));
33447    }
33448
33449    #[test]
33450    fn detect_language_name_dot_h() {
33451        assert_eq!(detect_language_name("defs.h"), Some("C"));
33452    }
33453
33454    #[test]
33455    fn detect_language_name_dot_cpp() {
33456        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33457    }
33458
33459    #[test]
33460    fn detect_language_name_dot_py() {
33461        assert_eq!(detect_language_name("script.py"), Some("Python"));
33462    }
33463
33464    #[test]
33465    fn detect_language_name_dot_ps1() {
33466        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33467    }
33468
33469    #[test]
33470    fn detect_language_name_dot_cs() {
33471        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33472    }
33473
33474    #[test]
33475    fn detect_language_name_dot_sh() {
33476        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33477    }
33478
33479    #[test]
33480    fn detect_language_name_unknown_txt() {
33481        assert_eq!(detect_language_name("notes.txt"), None);
33482    }
33483
33484    // ── language_icon_file ────────────────────────────────────────────────────
33485
33486    #[test]
33487    fn language_icon_file_c() {
33488        assert_eq!(language_icon_file("C"), Some("c.png"));
33489    }
33490
33491    #[test]
33492    fn language_icon_file_python() {
33493        assert_eq!(language_icon_file("Python"), Some("python.png"));
33494    }
33495
33496    #[test]
33497    fn language_icon_file_dockerfile() {
33498        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33499    }
33500
33501    #[test]
33502    fn language_icon_file_rust_is_none() {
33503        assert!(language_icon_file("Rust").is_none());
33504    }
33505
33506    #[test]
33507    fn language_icon_file_unknown_is_none() {
33508        assert!(language_icon_file("Fortran").is_none());
33509    }
33510
33511    // ── language_inline_svg ───────────────────────────────────────────────────
33512
33513    #[test]
33514    fn language_inline_svg_rust_is_svg() {
33515        let svg = language_inline_svg("Rust").unwrap();
33516        assert!(svg.starts_with("<svg"));
33517    }
33518
33519    #[test]
33520    fn language_inline_svg_typescript_is_some() {
33521        assert!(language_inline_svg("TypeScript").is_some());
33522    }
33523
33524    #[test]
33525    fn language_inline_svg_unknown_is_none() {
33526        assert!(language_inline_svg("Fortran").is_none());
33527    }
33528
33529    // ── classify_preview_file ─────────────────────────────────────────────────
33530
33531    #[test]
33532    fn classify_preview_file_c_supported() {
33533        assert!(matches!(
33534            classify_preview_file("main.c"),
33535            PreviewKind::Supported
33536        ));
33537    }
33538
33539    #[test]
33540    fn classify_preview_file_python_supported() {
33541        assert!(matches!(
33542            classify_preview_file("script.py"),
33543            PreviewKind::Supported
33544        ));
33545    }
33546
33547    #[test]
33548    fn classify_preview_file_png_skipped() {
33549        assert!(matches!(
33550            classify_preview_file("image.png"),
33551            PreviewKind::Skipped
33552        ));
33553    }
33554
33555    #[test]
33556    fn classify_preview_file_zip_skipped() {
33557        assert!(matches!(
33558            classify_preview_file("archive.zip"),
33559            PreviewKind::Skipped
33560        ));
33561    }
33562
33563    #[test]
33564    fn classify_preview_file_min_js_skipped() {
33565        assert!(matches!(
33566            classify_preview_file("bundle.min.js"),
33567            PreviewKind::Skipped
33568        ));
33569    }
33570
33571    #[test]
33572    fn classify_preview_file_rs_unsupported() {
33573        assert!(matches!(
33574            classify_preview_file("main.rs"),
33575            PreviewKind::Unsupported
33576        ));
33577    }
33578
33579    // ── preview_relative_path ─────────────────────────────────────────────────
33580
33581    #[test]
33582    fn preview_relative_path_strips_root() {
33583        let root = PathBuf::from("/project");
33584        let path = PathBuf::from("/project/src/main.c");
33585        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33586    }
33587
33588    #[test]
33589    fn preview_relative_path_unrooted_includes_filename() {
33590        let root = PathBuf::from("/other");
33591        let path = PathBuf::from("/project/src/main.c");
33592        let result = preview_relative_path(&root, &path);
33593        assert!(result.contains("main.c"));
33594    }
33595
33596    #[test]
33597    fn preview_relative_path_uses_forward_slashes() {
33598        let root = PathBuf::from("/project");
33599        let path = PathBuf::from("/project/a/b/c.py");
33600        assert!(!preview_relative_path(&root, &path).contains('\\'));
33601    }
33602
33603    // ── wildcard_match ────────────────────────────────────────────────────────
33604
33605    #[test]
33606    fn wildcard_match_exact_equal() {
33607        assert!(wildcard_match("foo", "foo"));
33608    }
33609
33610    #[test]
33611    fn wildcard_match_exact_mismatch() {
33612        assert!(!wildcard_match("foo", "bar"));
33613    }
33614
33615    #[test]
33616    fn wildcard_match_star_suffix() {
33617        assert!(wildcard_match("*.rs", "main.rs"));
33618    }
33619
33620    #[test]
33621    fn wildcard_match_star_middle_requires_suffix() {
33622        assert!(!wildcard_match("a*b", "ac"));
33623    }
33624
33625    #[test]
33626    fn wildcard_match_question_mark_single_char() {
33627        assert!(wildcard_match("f?o", "foo"));
33628    }
33629
33630    #[test]
33631    fn wildcard_match_double_star_nested() {
33632        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33633    }
33634
33635    #[test]
33636    fn wildcard_match_star_directory_entry() {
33637        assert!(wildcard_match("vendor/*", "vendor/crate"));
33638    }
33639
33640    #[test]
33641    fn wildcard_match_no_cross_prefix() {
33642        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33643    }
33644
33645    // ── should_skip_preview_directory ────────────────────────────────────────
33646
33647    #[test]
33648    fn should_skip_empty_relative_is_false() {
33649        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33650    }
33651
33652    #[test]
33653    fn should_skip_matching_pattern() {
33654        assert!(should_skip_preview_directory(
33655            "vendor",
33656            &["vendor".to_string()]
33657        ));
33658    }
33659
33660    #[test]
33661    fn should_skip_non_matching() {
33662        assert!(!should_skip_preview_directory(
33663            "src",
33664            &["vendor".to_string()]
33665        ));
33666    }
33667
33668    #[test]
33669    fn should_skip_wildcard_prefix() {
33670        assert!(should_skip_preview_directory(
33671            "target/debug",
33672            &["target*".to_string()]
33673        ));
33674    }
33675
33676    // ── should_include_preview_file ───────────────────────────────────────────
33677
33678    #[test]
33679    fn should_include_empty_relative_always_true() {
33680        assert!(should_include_preview_file("", &[], &[]));
33681    }
33682
33683    #[test]
33684    fn should_include_no_patterns_includes_all() {
33685        assert!(should_include_preview_file("src/main.c", &[], &[]));
33686    }
33687
33688    #[test]
33689    fn should_include_excluded_by_pattern() {
33690        assert!(!should_include_preview_file(
33691            "vendor/lib.c",
33692            &[],
33693            &["vendor/*".to_string()]
33694        ));
33695    }
33696
33697    #[test]
33698    fn should_include_include_pattern_filters() {
33699        assert!(!should_include_preview_file(
33700            "tests/test_foo.c",
33701            &["src/*".to_string()],
33702            &[]
33703        ));
33704    }
33705
33706    // ── escape_html ───────────────────────────────────────────────────────────
33707
33708    #[test]
33709    fn escape_html_ampersand() {
33710        assert_eq!(escape_html("a&b"), "a&amp;b");
33711    }
33712
33713    #[test]
33714    fn escape_html_angle_brackets() {
33715        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
33716    }
33717
33718    #[test]
33719    fn escape_html_double_quote() {
33720        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
33721    }
33722
33723    #[test]
33724    fn escape_html_single_quote() {
33725        assert_eq!(escape_html("it's"), "it&#39;s");
33726    }
33727
33728    #[test]
33729    fn escape_html_plain_text_unchanged() {
33730        assert_eq!(escape_html("hello world"), "hello world");
33731    }
33732
33733    // ── sum_added / removed / unmodified code lines ───────────────────────────
33734
33735    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33736        sloc_core::ScanComparison {
33737            summary: sloc_core::SummaryDelta {
33738                baseline_run_id: "base".to_string(),
33739                current_run_id: "curr".to_string(),
33740                baseline_timestamp: chrono::Utc::now(),
33741                current_timestamp: chrono::Utc::now(),
33742                baseline_files: 4,
33743                current_files: 4,
33744                files_analyzed_delta: 0,
33745                baseline_code: 330,
33746                current_code: 400,
33747                code_lines_delta: 70,
33748                baseline_comments: 0,
33749                current_comments: 0,
33750                comment_lines_delta: 0,
33751                blank_lines_delta: 0,
33752                total_lines_delta: 70,
33753                coverage_lines_hit_delta: None,
33754                coverage_line_pct_delta: None,
33755                baseline_coverage_line_pct: None,
33756                current_coverage_line_pct: None,
33757            },
33758            file_deltas: vec![
33759                sloc_core::FileDelta {
33760                    relative_path: "added.rs".to_string(),
33761                    language: Some("Rust".to_string()),
33762                    status: FileChangeStatus::Added,
33763                    baseline_code: 0,
33764                    current_code: 100,
33765                    code_delta: 100,
33766                    baseline_comment: 0,
33767                    current_comment: 0,
33768                    comment_delta: 0,
33769                    baseline_blank: 0,
33770                    current_blank: 0,
33771                    blank_delta: 0,
33772                    total_delta: 100,
33773                },
33774                sloc_core::FileDelta {
33775                    relative_path: "removed.rs".to_string(),
33776                    language: Some("Rust".to_string()),
33777                    status: FileChangeStatus::Removed,
33778                    baseline_code: 50,
33779                    current_code: 0,
33780                    code_delta: -50,
33781                    baseline_comment: 0,
33782                    current_comment: 0,
33783                    comment_delta: 0,
33784                    baseline_blank: 0,
33785                    current_blank: 0,
33786                    blank_delta: 0,
33787                    total_delta: -50,
33788                },
33789                sloc_core::FileDelta {
33790                    relative_path: "modified.rs".to_string(),
33791                    language: Some("Rust".to_string()),
33792                    status: FileChangeStatus::Modified,
33793                    baseline_code: 80,
33794                    current_code: 100,
33795                    code_delta: 20,
33796                    baseline_comment: 0,
33797                    current_comment: 0,
33798                    comment_delta: 0,
33799                    baseline_blank: 0,
33800                    current_blank: 0,
33801                    blank_delta: 0,
33802                    total_delta: 20,
33803                },
33804                sloc_core::FileDelta {
33805                    relative_path: "unchanged.rs".to_string(),
33806                    language: Some("Rust".to_string()),
33807                    status: FileChangeStatus::Unchanged,
33808                    baseline_code: 200,
33809                    current_code: 200,
33810                    code_delta: 0,
33811                    baseline_comment: 0,
33812                    current_comment: 0,
33813                    comment_delta: 0,
33814                    baseline_blank: 0,
33815                    current_blank: 0,
33816                    blank_delta: 0,
33817                    total_delta: 0,
33818                },
33819            ],
33820            files_added: 1,
33821            files_removed: 1,
33822            files_modified: 1,
33823            files_unchanged: 1,
33824        }
33825    }
33826
33827    #[test]
33828    fn sum_added_counts_added_and_positive_modified() {
33829        let cmp = make_mixed_scan_comparison();
33830        assert_eq!(sum_added_code_lines(&cmp), 120);
33831    }
33832
33833    #[test]
33834    fn sum_removed_counts_removed_baseline() {
33835        let cmp = make_mixed_scan_comparison();
33836        assert_eq!(sum_removed_code_lines(&cmp), 50);
33837    }
33838
33839    #[test]
33840    fn sum_unmodified_counts_unchanged_files() {
33841        let cmp = make_mixed_scan_comparison();
33842        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33843    }
33844
33845    // ── detect_coverage_tool ──────────────────────────────────────────────────
33846
33847    #[test]
33848    fn detect_coverage_tool_rust_project() {
33849        let dir = tempfile::tempdir().unwrap();
33850        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33851        let (tool, cmd) = detect_coverage_tool(dir.path());
33852        assert_eq!(tool, Some("cargo-llvm-cov"));
33853        assert!(cmd.is_some());
33854    }
33855
33856    #[test]
33857    fn detect_coverage_tool_java_gradle() {
33858        let dir = tempfile::tempdir().unwrap();
33859        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33860        let (tool, _) = detect_coverage_tool(dir.path());
33861        assert_eq!(tool, Some("jacoco"));
33862    }
33863
33864    #[test]
33865    fn detect_coverage_tool_python_pyproject() {
33866        let dir = tempfile::tempdir().unwrap();
33867        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33868        let (tool, _) = detect_coverage_tool(dir.path());
33869        assert_eq!(tool, Some("pytest-cov"));
33870    }
33871
33872    #[test]
33873    fn detect_coverage_tool_unknown_project() {
33874        let dir = tempfile::tempdir().unwrap();
33875        let (tool, cmd) = detect_coverage_tool(dir.path());
33876        assert!(tool.is_none() && cmd.is_none());
33877    }
33878
33879    // ── sanitize_path_str / display_path ─────────────────────────────────────
33880
33881    #[test]
33882    fn sanitize_path_str_unc_drive_stripped() {
33883        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33884    }
33885
33886    #[test]
33887    fn sanitize_path_str_unc_network_stripped() {
33888        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33889    }
33890
33891    #[test]
33892    fn sanitize_path_str_plain_path_unchanged() {
33893        assert_eq!(
33894            sanitize_path_str("/home/user/project"),
33895            "/home/user/project"
33896        );
33897    }
33898
33899    #[test]
33900    fn display_path_plain_linux_unchanged() {
33901        assert_eq!(
33902            display_path(Path::new("/home/user/project")),
33903            "/home/user/project"
33904        );
33905    }
33906
33907    #[test]
33908    fn display_path_unc_drive_stripped() {
33909        let result = display_path(Path::new(r"\\?\C:\Users\user"));
33910        assert_eq!(result, r"C:\Users\user");
33911    }
33912
33913    #[test]
33914    fn display_path_unc_network_stripped() {
33915        let result = display_path(Path::new(r"\\?\UNC\server\share"));
33916        assert_eq!(result, r"\\server\share");
33917    }
33918}
33919
33920#[cfg(test)]
33921mod coverage_boost_unit_tests {
33922    use super::*;
33923    use std::path::{Path, PathBuf};
33924
33925    // Both scenarios live in one test (sequential, under a Tokio runtime) because
33926    // load_runtime_security_config spawns a pruning task and mutates process-global
33927    // env vars — parallel sub-tests would race on both.
33928    #[tokio::test]
33929    async fn runtime_security_config_scenarios() {
33930        std::env::remove_var("SLOC_API_KEYS");
33931        std::env::remove_var("SLOC_API_KEY");
33932        std::env::remove_var("SLOC_TLS_CERT");
33933        std::env::remove_var("SLOC_TLS_KEY");
33934        std::env::remove_var("SLOC_TRUST_PROXY");
33935        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33936        let cfg = load_runtime_security_config(false);
33937        assert!(cfg.api_keys.is_empty());
33938        assert!(!cfg.tls_enabled);
33939        assert!(!cfg.trust_proxy);
33940
33941        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33942        std::env::set_var("SLOC_TRUST_PROXY", "1");
33943        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33944        std::env::set_var("SLOC_RATE_LIMIT", "250");
33945        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33946        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33947        let cfg = load_runtime_security_config(true);
33948        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33949        assert!(cfg.trust_proxy);
33950        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33951        std::env::remove_var("SLOC_API_KEYS");
33952        std::env::remove_var("SLOC_TRUST_PROXY");
33953        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33954        std::env::remove_var("SLOC_RATE_LIMIT");
33955        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33956        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33957    }
33958
33959    #[test]
33960    fn cors_layer_builds_both_modes() {
33961        let _ = build_cors_layer(true);
33962        let _ = build_cors_layer(false);
33963    }
33964
33965    #[test]
33966    fn primary_lan_ip_callable() {
33967        // May be Some or None depending on the host; both are valid.
33968        let _ = primary_lan_ip();
33969    }
33970
33971    #[test]
33972    fn safe_redirect_allows_relative_rejects_absolute() {
33973        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33974        assert_eq!(safe_redirect("https://evil.example/x"), "/");
33975        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33976        assert_eq!(default_redirect(), "/view-reports");
33977    }
33978
33979    #[test]
33980    fn tarball_size_caps_env_override() {
33981        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33982        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33983        let (c, d) = parse_tarball_size_caps();
33984        assert_eq!(c, 1024 * 1024);
33985        assert_eq!(d, 2 * 1024 * 1024);
33986        std::env::remove_var("SLOC_MAX_TARBALL_MB");
33987        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33988        let (c2, _) = parse_tarball_size_caps();
33989        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33990    }
33991
33992    #[test]
33993    fn upload_path_helpers() {
33994        let base = upload_base_dir();
33995        let staged = upload_staging_path("abc123");
33996        assert!(staged.starts_with(&base));
33997        assert!(
33998            is_upload_tmp_path(&staged),
33999            "staging path is an upload tmp path"
34000        );
34001        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34002    }
34003
34004    #[test]
34005    fn git_clones_dir_env_override() {
34006        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34007        let def = resolve_git_clones_dir(Path::new("/out"));
34008        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34009        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34010        assert_eq!(
34011            resolve_git_clones_dir(Path::new("/out")),
34012            PathBuf::from("/custom/clones")
34013        );
34014        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34015    }
34016
34017    #[test]
34018    fn html_report_file_detection() {
34019        let dir = std::env::temp_dir().join("sloc_html_detect");
34020        let _ = std::fs::create_dir_all(&dir);
34021        let good = dir.join("report_x.html");
34022        std::fs::write(&good, "<html></html>").unwrap();
34023        let bad = dir.join("notes.txt");
34024        std::fs::write(&bad, "x").unwrap();
34025        assert!(is_html_report_file(&good));
34026        assert!(!is_html_report_file(&bad));
34027        assert!(find_html_report_in_dir(&dir).is_some());
34028        let _ = std::fs::remove_dir_all(&dir);
34029    }
34030
34031    #[test]
34032    fn multi_delta_class_and_format() {
34033        assert_eq!(multi_delta_class(5), "pos");
34034        assert_eq!(multi_delta_class(-5), "neg");
34035        assert_eq!(multi_delta_class(0), "zero");
34036        assert_eq!(multi_fmt_delta(3), "+3");
34037        assert_eq!(multi_fmt_delta(-3), "-3");
34038        assert_eq!(multi_fmt_delta(0), "0");
34039    }
34040
34041    #[test]
34042    fn git_clone_dest_sanitizes() {
34043        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34044        assert!(dest.starts_with("/clones"));
34045        let name = dest.file_name().unwrap().to_str().unwrap();
34046        assert!(name
34047            .chars()
34048            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34049    }
34050}
34051
34052#[cfg(test)]
34053mod tests_private {
34054    use super::*;
34055    use std::io::Read;
34056
34057    #[test]
34058    fn size_limit_reader_zero_remaining_returns_error() {
34059        let data = b"hello world";
34060        let mut reader = SizeLimitReader {
34061            inner: &data[..],
34062            remaining: 0,
34063        };
34064        let mut buf = [0u8; 4];
34065        assert!(reader.read(&mut buf).is_err());
34066    }
34067
34068    #[test]
34069    fn size_limit_reader_counts_bytes() {
34070        let data = b"hello world";
34071        let mut reader = SizeLimitReader {
34072            inner: &data[..],
34073            remaining: 5,
34074        };
34075        let mut buf = [0u8; 4];
34076        let n = reader.read(&mut buf).unwrap();
34077        assert_eq!(n, 4);
34078        assert_eq!(reader.remaining, 1);
34079    }
34080
34081    #[test]
34082    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34083        let uuid = "12345678-1234-1234-1234-123456789012";
34084        let (id, path) = resolve_or_create_staging(Some(uuid));
34085        assert_eq!(id, uuid);
34086        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34087    }
34088
34089    #[test]
34090    fn resolve_or_create_staging_with_none_creates_new() {
34091        let (id1, _) = resolve_or_create_staging(None);
34092        let (id2, _) = resolve_or_create_staging(None);
34093        assert_ne!(id1, id2);
34094    }
34095
34096    #[test]
34097    fn resolve_or_create_staging_with_path_separator_creates_new() {
34098        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
34099        let (id, _) = resolve_or_create_staging(Some("has/slash"));
34100        assert_ne!(id, "has/slash");
34101    }
34102
34103    #[test]
34104    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34105        use std::net::IpAddr;
34106        use std::str::FromStr;
34107        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34108        let ip = IpAddr::from_str("192.168.1.1").unwrap();
34109        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34110    }
34111
34112    #[test]
34113    fn is_auth_locked_out_expired_entry_removed() {
34114        use std::net::IpAddr;
34115        use std::str::FromStr;
34116        let limiter = IpRateLimiter::new(
34117            Duration::from_mins(1),
34118            100,
34119            1, // 1 failure triggers lockout
34120            Duration::from_millis(1),
34121        );
34122        let ip = IpAddr::from_str("192.168.1.2").unwrap();
34123        limiter.record_auth_failure(ip);
34124        // Wait for the 1ms window to expire
34125        std::thread::sleep(Duration::from_millis(10));
34126        // Expired entry should be removed, returning false
34127        assert!(!limiter.is_auth_locked_out(ip));
34128    }
34129
34130    #[test]
34131    fn is_auth_locked_out_within_window_returns_true() {
34132        use std::net::IpAddr;
34133        use std::str::FromStr;
34134        let limiter = IpRateLimiter::new(
34135            Duration::from_mins(1),
34136            100,
34137            2, // 2 failures triggers lockout
34138            Duration::from_hours(1),
34139        );
34140        let ip = IpAddr::from_str("192.168.1.3").unwrap();
34141        limiter.record_auth_failure(ip);
34142        limiter.record_auth_failure(ip);
34143        assert!(limiter.is_auth_locked_out(ip));
34144    }
34145
34146    // ── output_folder_hint ───────────────────────────────────────────────────────
34147
34148    #[test]
34149    fn output_folder_hint_strips_json_subdir() {
34150        use std::path::Path;
34151        let path = Path::new("/output/scan1/json/result.json");
34152        let hint = output_folder_hint(path);
34153        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34154    }
34155
34156    #[test]
34157    fn output_folder_hint_strips_html_subdir() {
34158        use std::path::Path;
34159        let path = Path::new("/output/scan1/html/report.html");
34160        let hint = output_folder_hint(path);
34161        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34162    }
34163
34164    #[test]
34165    fn output_folder_hint_strips_pdf_subdir() {
34166        use std::path::Path;
34167        let path = Path::new("/output/scan1/pdf/report.pdf");
34168        let hint = output_folder_hint(path);
34169        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34170    }
34171
34172    #[test]
34173    fn output_folder_hint_strips_excel_subdir() {
34174        use std::path::Path;
34175        let path = Path::new("/output/scan1/excel/report.xlsx");
34176        let hint = output_folder_hint(path);
34177        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34178    }
34179
34180    #[test]
34181    fn output_folder_hint_flat_layout_returns_direct_parent() {
34182        use std::path::Path;
34183        let path = Path::new("/output/scan1/result.json");
34184        let hint = output_folder_hint(path);
34185        assert!(
34186            hint.ends_with("scan1"),
34187            "expected direct parent, got: {hint}"
34188        );
34189    }
34190
34191    #[test]
34192    fn output_folder_hint_other_subdir_name_not_stripped() {
34193        use std::path::Path;
34194        // "data" is not one of the named artifact subdirs — parent is kept as-is
34195        let path = Path::new("/output/scan1/data/result.json");
34196        let hint = output_folder_hint(path);
34197        assert!(
34198            hint.ends_with("data"),
34199            "non-artifact subdir must not be stripped, got: {hint}"
34200        );
34201    }
34202
34203    // ── find_file_by_ext ─────────────────────────────────────────────────────────
34204
34205    #[test]
34206    fn find_file_by_ext_finds_matching_file() {
34207        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34208        let _ = fs::create_dir_all(&dir);
34209        let f = dir.join("report.pdf");
34210        let _ = fs::write(&f, b"dummy");
34211        let result = find_file_by_ext(&dir, "pdf");
34212        assert!(result.is_some(), "expected to find report.pdf");
34213        let _ = fs::remove_dir_all(&dir);
34214    }
34215
34216    #[test]
34217    fn find_file_by_ext_returns_none_for_missing_ext() {
34218        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34219        let _ = fs::create_dir_all(&dir);
34220        let f = dir.join("report.json");
34221        let _ = fs::write(&f, b"{}");
34222        let result = find_file_by_ext(&dir, "pdf");
34223        assert!(result.is_none());
34224        let _ = fs::remove_dir_all(&dir);
34225    }
34226
34227    #[test]
34228    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34229        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34230        assert!(find_file_by_ext(dir, "json").is_none());
34231    }
34232
34233    // ── collect_result_json_candidates ───────────────────────────────────────────
34234
34235    #[test]
34236    fn collect_result_json_candidates_flat_root() {
34237        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34238        let _ = fs::create_dir_all(&root);
34239        let _ = fs::write(root.join("result.json"), b"{}");
34240        let candidates = collect_result_json_candidates(&root);
34241        assert!(!candidates.is_empty(), "should find result.json at root");
34242        let _ = fs::remove_dir_all(&root);
34243    }
34244
34245    #[test]
34246    fn collect_result_json_candidates_legacy_subdir() {
34247        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34248        let sub = root.join("scanA");
34249        let _ = fs::create_dir_all(&sub);
34250        let _ = fs::write(sub.join("result.json"), b"{}");
34251        let candidates = collect_result_json_candidates(&root);
34252        assert!(
34253            !candidates.is_empty(),
34254            "should find result.json in legacy subdir"
34255        );
34256        let _ = fs::remove_dir_all(&root);
34257    }
34258
34259    #[test]
34260    fn collect_result_json_candidates_structured_json_subdir() {
34261        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34262        let json_sub = root.join("scanB").join("json");
34263        let _ = fs::create_dir_all(&json_sub);
34264        let _ = fs::write(json_sub.join("result.json"), b"{}");
34265        let candidates = collect_result_json_candidates(&root);
34266        assert!(
34267            !candidates.is_empty(),
34268            "should find result.json inside <subdir>/json/"
34269        );
34270        let _ = fs::remove_dir_all(&root);
34271    }
34272
34273    #[test]
34274    fn collect_result_json_candidates_empty_dir() {
34275        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34276        let _ = fs::create_dir_all(&root);
34277        let candidates = collect_result_json_candidates(&root);
34278        assert!(candidates.is_empty());
34279        let _ = fs::remove_dir_all(&root);
34280    }
34281}