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/// Test router where the analysis semaphore is pre-exhausted (0 permits).
882/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
883pub fn make_test_router_exhausted_semaphore() -> Router {
884    let mut state = test_app_state("sloc_test_exhaust");
885    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
886    build_router(state)
887}
888
889/// Test router with a very tight rate limit (3 req/min). The third request from
890/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
891pub fn make_test_router_tight_rate_limit() -> Router {
892    let mut state = test_app_state("sloc_test_rate");
893    state.rate_limiter = Arc::new(IpRateLimiter::new(
894        Duration::from_mins(1),
895        2,
896        5,
897        Duration::from_secs(5),
898    ));
899    build_router(state)
900}
901
902/// Test router with a very tight auth lockout (threshold=2, window=200ms).
903/// Used by tests that need to trigger and verify the auth lockout response.
904pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
905    let mut state = test_app_state("sloc_test_auth_lockout");
906    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
907    state.rate_limiter = Arc::new(IpRateLimiter::new(
908        Duration::from_mins(1),
909        600,
910        2,                          // 2 failures triggers lockout
911        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
912    ));
913    build_router(state)
914}
915
916struct RuntimeSecurityConfig {
917    api_keys: Vec<secrecy::SecretBox<String>>,
918    tls_cert: Option<String>,
919    tls_key: Option<String>,
920    tls_enabled: bool,
921    trust_proxy: bool,
922    trusted_proxy_ips: Vec<IpAddr>,
923    rate_limiter: Arc<IpRateLimiter>,
924}
925
926fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
927    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
928        .or_else(|_| std::env::var("SLOC_API_KEY"))
929        .unwrap_or_default()
930        .split(',')
931        .map(str::trim)
932        .filter(|s| !s.is_empty())
933        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
934        .collect();
935    if server_mode && api_keys.is_empty() {
936        println!(
937            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
938             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
939        );
940    }
941    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
942    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
943    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
944    if server_mode && !tls_enabled {
945        println!(
946            "WARNING: TLS is not configured. Traffic is cleartext. \
947             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
948             or terminate TLS at a reverse proxy (nginx, caddy)."
949        );
950    }
951    if server_mode {
952        println!(
953            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
954             to restrict cross-origin access (comma-separated)."
955        );
956    }
957    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
958    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
959        .unwrap_or_default()
960        .split(',')
961        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
962        .collect();
963    if trust_proxy {
964        if trusted_proxy_ips.is_empty() {
965            println!(
966                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
967                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
968                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
969            );
970        } else {
971            println!(
972                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
973                trusted_proxy_ips
974                    .iter()
975                    .map(std::string::ToString::to_string)
976                    .collect::<Vec<_>>()
977                    .join(", ")
978            );
979        }
980    } else if server_mode {
981        println!(
982            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
983             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
984             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
985             enable per-client rate limiting via X-Forwarded-For."
986        );
987    }
988    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
989        println!(
990            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
991             DISABLED for all git operations. Remove this variable before production use."
992        );
993    }
994    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
995        .ok()
996        .and_then(|v| v.parse::<u32>().ok())
997        .unwrap_or(10);
998    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
999        .ok()
1000        .and_then(|v| v.parse::<u64>().ok())
1001        .unwrap_or(3600);
1002    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1003    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1004    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1005    let default_rpm: usize = if server_mode { 120 } else { 600 };
1006    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1007        .ok()
1008        .and_then(|v| v.parse::<usize>().ok())
1009        .unwrap_or(default_rpm);
1010    let rate_limiter = Arc::new(IpRateLimiter::new(
1011        Duration::from_mins(1),
1012        rate_limit_rpm,
1013        auth_lockout_threshold,
1014        Duration::from_secs(auth_lockout_secs),
1015    ));
1016    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1017    RuntimeSecurityConfig {
1018        api_keys,
1019        tls_cert,
1020        tls_key,
1021        tls_enabled,
1022        trust_proxy,
1023        trusted_proxy_ips,
1024        rate_limiter,
1025    }
1026}
1027
1028/// # Errors
1029///
1030/// Returns an error if the server fails to bind to the configured address or
1031/// if the TLS configuration cannot be loaded.
1032///
1033/// # Panics
1034///
1035/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1036#[allow(clippy::too_many_lines)]
1037pub async fn serve(config: AppConfig) -> Result<()> {
1038    let bind_address = config.web.bind_address.clone();
1039    let server_mode = config.web.server_mode;
1040    let output_root = resolve_output_root(None);
1041    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1042    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1043        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1044    let mut registry = ScanRegistry::load(&registry_path);
1045    registry.prune_stale();
1046    let _ = registry.save(&registry_path);
1047
1048    let sec = load_runtime_security_config(server_mode);
1049    spawn_upload_staging_cleanup();
1050
1051    let git_clones_dir = resolve_git_clones_dir(&output_root);
1052    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1053        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1054    let schedules = ScheduleStore::load(&schedules_path);
1055    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1056        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1057    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1058    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1059        |_| output_root.join("confluence_config.json"),
1060        PathBuf::from,
1061    );
1062    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1063    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1064        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1065    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1066    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1067        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1068    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1069
1070    let state = AppState {
1071        base_config: config,
1072        artifacts: Arc::new(Mutex::new(HashMap::new())),
1073        async_runs: Arc::new(Mutex::new(HashMap::new())),
1074        registry: Arc::new(Mutex::new(registry)),
1075        registry_path,
1076        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1077        server_mode,
1078        tls_enabled: sec.tls_enabled,
1079        api_keys: Arc::new(sec.api_keys),
1080        rate_limiter: sec.rate_limiter,
1081        trust_proxy: sec.trust_proxy,
1082        trusted_proxy_ips: sec.trusted_proxy_ips,
1083        git_clones_dir,
1084        schedules: Arc::new(Mutex::new(schedules)),
1085        schedules_path,
1086        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1087        scan_profiles_path,
1088        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1089        confluence: Arc::new(Mutex::new(confluence)),
1090        confluence_path,
1091        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1092        watched_dirs_path,
1093        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1094        cleanup_policy_path,
1095        cleanup_task_handle: Arc::new(Mutex::new(None)),
1096    };
1097
1098    restart_poll_schedules(&state).await;
1099    warn_insecure_gitlab_webhooks(&state).await;
1100
1101    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1102    {
1103        let enabled = state
1104            .cleanup_policy
1105            .lock()
1106            .await
1107            .policy
1108            .as_ref()
1109            .is_some_and(|p| p.enabled);
1110        if enabled {
1111            let handle = spawn_cleanup_policy_task(state.clone());
1112            *state.cleanup_task_handle.lock().await = Some(handle);
1113        }
1114    }
1115
1116    let app = build_router(state.clone());
1117
1118    // Try the configured port first, then step up through a few alternatives.
1119    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1120    // kernel zombie (visible in netstat but owned by no living process).  Rather
1121    // than failing, we auto-select the next free port and tell the user.
1122    let preferred: SocketAddr = bind_address
1123        .parse()
1124        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1125    let (listener, addr) = {
1126        let candidates = (0u16..=9).map(|offset| {
1127            let mut a = preferred;
1128            a.set_port(preferred.port().saturating_add(offset));
1129            a
1130        });
1131        let mut found = None;
1132        for candidate in candidates {
1133            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1134                found = Some((l, candidate));
1135                break;
1136            }
1137        }
1138        found.ok_or_else(|| {
1139            anyhow::anyhow!(
1140                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1141                bind_address,
1142                preferred.port(),
1143                preferred.port().saturating_add(9)
1144            )
1145        })?
1146    };
1147    if addr != preferred {
1148        eprintln!(
1149            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1150             using {} instead.",
1151            preferred.port(),
1152            addr.port()
1153        );
1154    }
1155
1156    if sec.tls_enabled {
1157        let cert_path = sec
1158            .tls_cert
1159            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1160        let key_path = sec
1161            .tls_key
1162            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1163        let tls_config = build_tls_config(&cert_path, &key_path)
1164            .context("failed to load TLS certificate/key")?;
1165        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1166
1167        let url = format!("https://{addr}/");
1168        println!("OxideSLOC server running at {url} (TLS)");
1169        println!("Use Ctrl+C to stop.");
1170
1171        return serve_tls(listener, app, acceptor, server_mode).await;
1172    }
1173
1174    let url = format!("http://{addr}/");
1175    log_startup_url(&url, server_mode);
1176
1177    axum::serve(
1178        listener,
1179        app.into_make_service_with_connect_info::<SocketAddr>(),
1180    )
1181    .with_graceful_shutdown(shutdown_signal(server_mode))
1182    .await
1183    .context("web server terminated unexpectedly")
1184}
1185
1186/// Discover the primary non-loopback IPv4 address by asking the OS which
1187/// outbound interface it would use to reach a public address.  No packets are
1188/// sent — the UDP socket is only used to query the routing table.
1189fn primary_lan_ip() -> Option<String> {
1190    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1191    socket.connect("8.8.8.8:80").ok()?;
1192    let addr = socket.local_addr().ok()?;
1193    let ip = addr.ip();
1194    if ip.is_loopback() {
1195        return None;
1196    }
1197    Some(ip.to_string())
1198}
1199
1200/// Print the startup URL and, in local mode, open the browser and schedule it.
1201fn log_startup_url(url: &str, server_mode: bool) {
1202    if server_mode {
1203        println!("OxideSLOC server running at {url}");
1204        println!("Use Ctrl+C to stop.");
1205    } else {
1206        println!("OxideSLOC local web UI running at {url}");
1207        println!("Press Ctrl+C to stop the server.");
1208        let open_url = url.to_owned();
1209        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1210    }
1211}
1212
1213/// Open the given URL in the default system browser.
1214fn open_browser_tab(url: &str) {
1215    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1216    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1217    // first quoted token as a window title — both are fragile and shell-parsed. The
1218    // url.dll handler receives the URL as a single, non-shell argument.
1219    #[cfg(target_os = "windows")]
1220    let _ = std::process::Command::new("rundll32")
1221        .args(["url.dll,FileProtocolHandler", url])
1222        .stdout(Stdio::null())
1223        .stderr(Stdio::null())
1224        .spawn();
1225    #[cfg(target_os = "macos")]
1226    let _ = std::process::Command::new("open")
1227        .arg(url)
1228        .stdout(Stdio::null())
1229        .stderr(Stdio::null())
1230        .spawn();
1231    #[cfg(target_os = "linux")]
1232    let _ = std::process::Command::new("xdg-open")
1233        .arg(url)
1234        .stdout(Stdio::null())
1235        .stderr(Stdio::null())
1236        .spawn();
1237}
1238
1239/// Graceful-shutdown future: resolves on Ctrl-C.
1240async fn shutdown_signal(server_mode: bool) {
1241    if tokio::signal::ctrl_c().await.is_ok() {
1242        println!();
1243        if server_mode {
1244            println!("Shutting down OxideSLOC server...");
1245        } else {
1246            println!("Shutting down OxideSLOC local web UI...");
1247        }
1248        println!("Server stopped cleanly.");
1249    }
1250}
1251
1252/// Load a rustls `ServerConfig` from PEM certificate and key files.
1253fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1254    use rustls_pki_types::pem::PemObject;
1255    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1256
1257    let cert_bytes =
1258        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1259    let key_bytes =
1260        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1261
1262    let cert_chain: Vec<CertificateDer<'static>> =
1263        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1264            .collect::<std::result::Result<_, _>>()
1265            .context("failed to parse TLS certificates")?;
1266
1267    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1268        .context("failed to parse TLS private key")?;
1269
1270    rustls::ServerConfig::builder()
1271        .with_no_client_auth()
1272        .with_single_cert(cert_chain, key)
1273        .context("failed to build TLS server config")
1274}
1275
1276/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1277async fn serve_tls(
1278    listener: tokio::net::TcpListener,
1279    app: Router,
1280    acceptor: tokio_rustls::TlsAcceptor,
1281    server_mode: bool,
1282) -> Result<()> {
1283    use hyper_util::rt::{TokioExecutor, TokioIo};
1284    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1285    use hyper_util::service::TowerToHyperService;
1286    use tower::{Service, ServiceExt};
1287
1288    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1289
1290    loop {
1291        tokio::select! {
1292            biased;
1293            _ = tokio::signal::ctrl_c() => {
1294                println!();
1295                if server_mode {
1296                    println!("Shutting down OxideSLOC server...");
1297                } else {
1298                    println!("Shutting down OxideSLOC local web UI...");
1299                }
1300                println!("Server stopped cleanly.");
1301                return Ok(());
1302            }
1303            result = listener.accept() => {
1304                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1305                let acceptor = acceptor.clone();
1306                let mut factory = make_svc.clone();
1307
1308                tokio::spawn(async move {
1309                    let tls = match acceptor.accept(tcp).await {
1310                        Ok(s) => s,
1311                        Err(e) => {
1312                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1313                            return;
1314                        }
1315                    };
1316                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1317                        Ok(f) => match Service::call(f, peer_addr).await {
1318                            Ok(s) => s,
1319                            Err(_) => return,
1320                        },
1321                        Err(_) => return,
1322                    };
1323                    let io = TokioIo::new(tls);
1324                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1325                        .serve_connection(io, TowerToHyperService::new(svc))
1326                        .await
1327                    {
1328                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1329                    }
1330                });
1331            }
1332        }
1333    }
1334}
1335
1336// auth moved to auth.rs
1337
1338fn build_cors_layer(server_mode: bool) -> CorsLayer {
1339    if server_mode {
1340        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1341            .unwrap_or_default()
1342            .split(',')
1343            .filter(|s| !s.is_empty())
1344            .filter_map(|s| s.trim().parse().ok())
1345            .collect();
1346        if allowed.is_empty() {
1347            return CorsLayer::new();
1348        }
1349        CorsLayer::new()
1350            .allow_origin(AllowOrigin::list(allowed))
1351            .allow_methods(AllowMethods::list([
1352                axum::http::Method::GET,
1353                axum::http::Method::POST,
1354            ]))
1355            .allow_headers(AllowHeaders::list([
1356                axum::http::header::AUTHORIZATION,
1357                axum::http::header::CONTENT_TYPE,
1358            ]))
1359    } else {
1360        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1361            let s = origin.to_str().unwrap_or("");
1362            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1363        }))
1364    }
1365}
1366
1367async fn add_security_headers(
1368    State(state): State<AppState>,
1369    mut req: Request<Body>,
1370    next: Next,
1371) -> Response {
1372    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1373    req.extensions_mut().insert(CspNonce(nonce.clone()));
1374    let mut resp = next.run(req).await;
1375    inject_page_fade_into_html(&mut resp, &nonce).await;
1376    let h = resp.headers_mut();
1377    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1378    h.insert(
1379        "X-Content-Type-Options",
1380        HeaderValue::from_static("nosniff"),
1381    );
1382    h.insert(
1383        "Referrer-Policy",
1384        HeaderValue::from_static("strict-origin-when-cross-origin"),
1385    );
1386    let csp = format!(
1387        "default-src 'self'; \
1388         style-src 'self' 'unsafe-inline'; \
1389         img-src 'self' data: blob:; \
1390         script-src 'self' 'nonce-{nonce}'; \
1391         font-src 'self' data:; \
1392         object-src 'none'; \
1393         frame-ancestors 'none'"
1394    );
1395    h.insert(
1396        "Content-Security-Policy",
1397        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1398            HeaderValue::from_static(
1399                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1400            )
1401        }),
1402    );
1403    h.insert(
1404        "X-Permitted-Cross-Domain-Policies",
1405        HeaderValue::from_static("none"),
1406    );
1407    h.insert(
1408        "Permissions-Policy",
1409        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1410    );
1411    h.insert(
1412        "Cross-Origin-Opener-Policy",
1413        HeaderValue::from_static("same-origin"),
1414    );
1415    h.insert(
1416        "Cross-Origin-Resource-Policy",
1417        HeaderValue::from_static("same-origin"),
1418    );
1419    if state.tls_enabled {
1420        h.insert(
1421            "Strict-Transport-Security",
1422            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1423        );
1424    }
1425    resp
1426}
1427
1428/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1429///
1430/// On state-changing methods, browser-driven cookie-authenticated requests must
1431/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1432/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1433///
1434/// Deliberately exempt:
1435/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1436/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1437///   ambient, so it is not CSRF-exploitable.
1438/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1439/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1440///   a browser performing a CSRF attack always sends `Origin`.
1441async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1442    use axum::http::Method;
1443
1444    let is_state_changing = matches!(
1445        *req.method(),
1446        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1447    );
1448    let path = req.uri().path();
1449    let has_token_auth = req.headers().contains_key("X-API-Key")
1450        || req
1451            .headers()
1452            .get(header::AUTHORIZATION)
1453            .and_then(|v| v.to_str().ok())
1454            .is_some_and(|v| v.starts_with("Bearer "));
1455
1456    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1457        return next.run(req).await;
1458    }
1459
1460    let headers = req.headers();
1461    let header_str = |name: &header::HeaderName| {
1462        headers
1463            .get(name)
1464            .and_then(|v| v.to_str().ok())
1465            .map(str::to_owned)
1466    };
1467    let origin = header_str(&header::ORIGIN);
1468    let referer = header_str(&header::REFERER);
1469    let host = header_str(&header::HOST);
1470
1471    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1472    let authority_of = |url: &str| -> Option<String> {
1473        url.split_once("://")
1474            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1475    };
1476
1477    let source_authority = origin
1478        .as_deref()
1479        .and_then(authority_of)
1480        .or_else(|| referer.as_deref().and_then(authority_of));
1481
1482    match (source_authority, host) {
1483        // Neither Origin nor Referer present: treat as a non-browser client.
1484        (None, _) => next.run(req).await,
1485        (Some(src), Some(h)) if src == h => next.run(req).await,
1486        (Some(src), host) => {
1487            tracing::warn!(
1488                event = "csrf_rejected",
1489                path = %path,
1490                origin = %src,
1491                host = ?host,
1492                "Cross-origin state-changing request rejected (CSRF guard)"
1493            );
1494            (
1495                StatusCode::FORBIDDEN,
1496                "403 Forbidden — cross-origin request rejected\n",
1497            )
1498                .into_response()
1499        }
1500    }
1501}
1502
1503/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1504/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1505/// is overkill — a short opacity fade gives a smooth page-to-page transition
1506/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1507/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1508/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1509/// light-mode flash for dark-theme users.
1510fn page_fade_html(nonce: &str) -> String {
1511    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1512    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1513    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1514    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1515    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1516    // through the entire body parse, which reads as a delay before navigation "begins"
1517    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1518    // 0 -> 1 cleanly, with no pre-paint hold.
1519    const STYLE: &str = r"<style>
1520@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1521.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1522body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1523@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1524</style>";
1525    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1526    // The click handler gives immediate feedback by fading the *content* out the moment a
1527    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1528    // preventDefault or delay navigation — the browser navigates instantly and the fade
1529    // plays opportunistically during the natural fetch window, so no latency is added.
1530    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1531    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1532    // if the click was actually a download (no unload) or the page is restored from bfcache.
1533    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');});})();";
1534    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1535}
1536
1537/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1538/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1539/// `<script>` — meant to be spliced in immediately after `<body>`.
1540///
1541/// It pairs the spinner with a **visibility gate**: from the first byte the page
1542/// content is held at `visibility:hidden` (only the overlay paints), so the user
1543/// never sees a half-rendered flash while charts/tables are still settling. On
1544/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1545/// still-opaque overlay, which then fades out one frame later — so the reveal is
1546/// of a finished page, with no glitch on either side of the transition.
1547///
1548/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1549/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1550/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1551fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1552    const TPL: &str = r#"<style nonce="__N__">
1553html.sloc-pending body{visibility:hidden;}
1554html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1555#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%);}
1556#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1557body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1558body.pdf-mode #rpt-loading-overlay{display:none!important;}
1559.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1560.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;}
1561.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;}
1562@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1563@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1564body.dark-theme .rpt-bg-blob{opacity:.36;}
1565.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;}
1566@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1567body.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);}
1568.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1569.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1570.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1571.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));}
1572@keyframes rpt-spin{to{transform:rotate(360deg);}}
1573.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;}
1574body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1575body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1576.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1577.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;}
1578@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1579.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1580.rpt-dot:nth-child(2){animation-delay:.28s;}
1581.rpt-dot:nth-child(3){animation-delay:.56s;}
1582@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1583.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1584.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1585.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;}
1586body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1587@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;}}
1588</style>
1589<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1590<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>
1591<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1592  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1593  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1594  <div class="rpt-load-card">
1595    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1596    <div class="rpt-spinner-wrap">
1597      <div class="rpt-spinner-track"></div>
1598      <div class="rpt-spinner"></div>
1599      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1600    </div>
1601    <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>
1602    <div class="rpt-status" id="rpt-status">__LABEL__</div>
1603    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1604  </div>
1605</div>
1606<script nonce="__N__">
1607(function(){
1608  var ov=document.getElementById('rpt-loading-overlay');
1609  var root=document.documentElement;
1610  function reveal(){root.classList.remove('sloc-pending');}
1611  if(!ov){reveal();return;}
1612  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1613  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1614  var mi=0,prog=0,done=false,start=Date.now();
1615  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1616  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1617  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1618  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1619  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1620  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1621  setProg(8);
1622  var msgTimer=setInterval(nextMsg,700);
1623  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);
1624  // These pages draw charts into known SVG containers that start empty and are
1625  // filled by JS once layout is available (some only after a ResizeObserver pass
1626  // post-`load`). Treat the page as ready only once every chart container present
1627  // actually has rendered content, so the overlay never lifts on a half-drawn page.
1628  function chartsRendered(){
1629    var sel=['#cmp-tl-svg','#mc-chart'];
1630    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1631    return true;
1632  }
1633  function finish(){
1634    if(done)return;done=true;
1635    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1636    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1637    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1638    reveal();
1639    requestAnimationFrame(function(){requestAnimationFrame(function(){
1640      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1641    });});
1642  }
1643  // Wait for `load` (resources + first layout), then poll until the charts have
1644  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1645  function afterLoad(){
1646    var loadAt=Date.now();
1647    (function poll(){
1648      if(done)return;
1649      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1650        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1651        return;
1652      }
1653      requestAnimationFrame(poll);
1654    })();
1655  }
1656  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1657  // Absolute safety net: never let the gate/overlay get stuck.
1658  setTimeout(function(){if(!done)finish();},HARD_CAP);
1659})();
1660</script>"#;
1661    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1662}
1663
1664/// Shared toast-notification assets + a global PDF-export helper, spliced into
1665/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
1666/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
1667/// placed just before `</body>`.
1668///
1669/// It defines two globals:
1670/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
1671///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
1672///   toast stays up until its returned handle's `.dismiss()` is called.
1673/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
1674///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
1675///   `/export/pdf`, triggers the download, then raises a success or error toast and
1676///   restores the button. Centralising this guarantees identical, obvious feedback
1677///   everywhere instead of a silent `alert()`-only failure path.
1678fn sloc_toast_assets(nonce: &str) -> String {
1679    const TPL: &str = r##"<style nonce="__N__">
1680#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;}
1681.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);}
1682.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1683.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1684.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;}
1685.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1686.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1687.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1688.sloc-toast-success{border-color:#bfe0cc;}
1689.sloc-toast-error{border-color:#e6b3b3;}
1690.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1691.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;}
1692@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1693.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;}
1694.sloc-toast-x:hover{opacity:1;}
1695body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1696body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1697body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1698body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1699@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1700</style>
1701<script nonce="__N__">
1702(function(){
1703  if(window.slocToast)return;
1704  function wrap(){
1705    var w=document.getElementById('sloc-toast-wrap');
1706    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);}
1707    return w;
1708  }
1709  window.slocToast=function(msg,opts){
1710    opts=opts||{};
1711    var type=opts.type||'info';
1712    var loading=type==='loading';
1713    var t=document.createElement('div');
1714    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1715    t.setAttribute('role',type==='error'?'alert':'status');
1716    var ico=loading
1717      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1718      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1719    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1720    t.querySelector('.sloc-toast-msg').textContent=String(msg);
1721    wrap().appendChild(t);
1722    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1723    var gone=false,timer=null;
1724    function close(){
1725      if(gone)return;gone=true;if(timer)clearTimeout(timer);
1726      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1727      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1728    }
1729    t.querySelector('.sloc-toast-x').addEventListener('click',close);
1730    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1731    if(ttl>0)timer=setTimeout(close,ttl);
1732    return {dismiss:close,el:t};
1733  };
1734  window.slocExportPdf=function(o){
1735    o=o||{};
1736    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1737    if(btn&&btn.disabled)return;
1738    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1739    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1740    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1741      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1742      .then(function(blob){
1743        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1744        document.body.appendChild(a);a.click();document.body.removeChild(a);
1745        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1746        load.dismiss();
1747        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1748      })
1749      .catch(function(e){
1750        load.dismiss();
1751        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1752      })
1753      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1754  };
1755})();
1756</script>"##;
1757    TPL.replace("__N__", nonce)
1758}
1759
1760/// Buffer an HTML response body and splice the page fade-in right after the
1761/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
1762/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
1763/// branded loading spinner for slow renders).
1764async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1765    let is_html = resp
1766        .headers()
1767        .get(header::CONTENT_TYPE)
1768        .and_then(|v| v.to_str().ok())
1769        .is_some_and(|v| v.starts_with("text/html"));
1770    if !is_html {
1771        return;
1772    }
1773    let body = std::mem::replace(resp.body_mut(), Body::empty());
1774    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1775        return;
1776    };
1777    let html = match String::from_utf8(bytes.to_vec()) {
1778        Ok(s) => s,
1779        Err(e) => {
1780            *resp.body_mut() = Body::from(e.into_bytes());
1781            return;
1782        }
1783    };
1784    if html.contains("id=\"rpt-loading-overlay\"") {
1785        *resp.body_mut() = Body::from(html);
1786        return;
1787    }
1788    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
1789    // avoids allocating a lowercased copy of the whole document on every request.
1790    // Fall back to a case-insensitive scan only if that fails (rare/never).
1791    let insert_at = html
1792        .find("<body")
1793        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1794        .or_else(|| {
1795            let lower = html.to_ascii_lowercase();
1796            lower
1797                .find("<body")
1798                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1799        });
1800    let new_html = match insert_at {
1801        Some(at) => {
1802            let mut out = String::with_capacity(html.len() + 1024);
1803            out.push_str(&html[..at]);
1804            out.push_str(&page_fade_html(nonce));
1805            out.push_str(&html[at..]);
1806            out
1807        }
1808        None => html,
1809    };
1810    resp.headers_mut().remove(header::CONTENT_LENGTH);
1811    *resp.body_mut() = Body::from(new_html);
1812}
1813
1814async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1815    let peer_ip = req
1816        .extensions()
1817        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1818        .map(|c| c.0.ip());
1819
1820    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1821    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1822    // header spoofing from direct connections.
1823    let ip = peer_ip
1824        .and_then(|peer| {
1825            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1826                req.headers()
1827                    .get("X-Forwarded-For")
1828                    .and_then(|v| v.to_str().ok())
1829                    .and_then(|s| s.split(',').next())
1830                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1831            } else {
1832                None
1833            }
1834        })
1835        .or(peer_ip)
1836        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1837
1838    if !state.rate_limiter.is_allowed(ip) {
1839        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1840            path = %req.uri().path(), "Rate limit exceeded");
1841        return (
1842            StatusCode::TOO_MANY_REQUESTS,
1843            [(header::RETRY_AFTER, "60")],
1844            "429 Too Many Requests\n",
1845        )
1846            .into_response();
1847    }
1848    next.run(req).await
1849}
1850
1851async fn splash(
1852    State(state): State<AppState>,
1853    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1854) -> impl IntoResponse {
1855    let lan_ip = if state.server_mode {
1856        primary_lan_ip()
1857    } else {
1858        None
1859    };
1860    let port = state
1861        .base_config
1862        .web
1863        .bind_address
1864        .rsplit(':')
1865        .next()
1866        .and_then(|p| p.parse::<u16>().ok())
1867        .unwrap_or(4317);
1868    let has_api_key = !state.api_keys.is_empty();
1869    let template = SplashTemplate {
1870        csp_nonce,
1871        server_mode: state.server_mode,
1872        lan_ip,
1873        port,
1874        version: env!("CARGO_PKG_VERSION"),
1875        has_api_key,
1876    };
1877    Html(
1878        template
1879            .render()
1880            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1881    )
1882}
1883
1884async fn index(
1885    State(state): State<AppState>,
1886    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1887    Query(query): Query<IndexQuery>,
1888) -> impl IntoResponse {
1889    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1890        let policy = query
1891            .mixed_line_policy
1892            .unwrap_or_else(|| "code_only".to_string());
1893        let behavior = query
1894            .binary_file_behavior
1895            .unwrap_or_else(|| "skip".to_string());
1896        let cfg = ScanConfig {
1897            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1898            path: query.path.unwrap_or_default(),
1899            include_globs: query.include_globs.unwrap_or_default(),
1900            exclude_globs: query.exclude_globs.unwrap_or_default(),
1901            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1902            mixed_line_policy: policy,
1903            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1904                != Some("off"),
1905            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1906            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1907            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1908                != Some("disabled"),
1909            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1910            binary_file_behavior: behavior,
1911            output_dir: query.output_dir.unwrap_or_default(),
1912            report_title: query.report_title.unwrap_or_default(),
1913            continuation_line_policy: query
1914                .continuation_line_policy
1915                .unwrap_or_else(default_each_physical_line),
1916            blank_in_block_comment_policy: query
1917                .blank_in_block_comment_policy
1918                .unwrap_or_else(default_count_as_comment),
1919            count_compiler_directives: query.count_compiler_directives.as_deref()
1920                != Some("disabled"),
1921            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
1922            style_col_threshold: query
1923                .style_col_threshold
1924                .as_deref()
1925                .and_then(|s| s.parse().ok())
1926                .unwrap_or(80),
1927            style_score_threshold: query
1928                .style_score_threshold
1929                .as_deref()
1930                .and_then(|s| s.parse().ok())
1931                .unwrap_or(0),
1932            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
1933            coverage_file: query.coverage_file.unwrap_or_default(),
1934            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
1935            complexity_alert: query
1936                .complexity_alert
1937                .as_deref()
1938                .and_then(|s| s.parse().ok())
1939                .unwrap_or(0),
1940            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
1941            activity_window: query
1942                .activity_window
1943                .as_deref()
1944                .and_then(|s| s.parse().ok())
1945                .unwrap_or(90),
1946        };
1947        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1948    } else {
1949        "{}".to_string()
1950    };
1951
1952    let git_repo = query.git_repo.unwrap_or_default();
1953    let git_ref = query.git_ref.unwrap_or_default();
1954
1955    let git_label = make_git_label(&git_repo, &git_ref);
1956    let git_output_dir = if git_label.is_empty() {
1957        String::new()
1958    } else {
1959        desktop_dir().join(&git_label).display().to_string()
1960    };
1961    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1962    let git_output_dir_json =
1963        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1964
1965    let template = IndexTemplate {
1966        version: env!("CARGO_PKG_VERSION"),
1967        prefill_json,
1968        csp_nonce,
1969        git_repo,
1970        git_ref,
1971        git_label_json,
1972        git_output_dir_json,
1973        server_mode: state.server_mode,
1974    };
1975
1976    Html(
1977        template
1978            .render()
1979            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1980    )
1981}
1982
1983async fn scan_setup_handler(
1984    State(state): State<AppState>,
1985    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1986) -> impl IntoResponse {
1987    let recent_scans_json = {
1988        let arr: Vec<serde_json::Value> = {
1989            let reg = state.registry.lock().await;
1990            reg.entries
1991                .iter()
1992                .rev()
1993                .take(6)
1994                .map(|e| {
1995                    let run_dir = e
1996                        .html_path
1997                        .as_ref()
1998                        .or(e.json_path.as_ref())
1999                        .and_then(|p| p.parent().map(PathBuf::from));
2000                    let config_val: Option<serde_json::Value> = run_dir
2001                        .and_then(|d| find_scan_config_in_dir(&d))
2002                        .and_then(|p| fs::read_to_string(&p).ok())
2003                        .and_then(|s| serde_json::from_str(&s).ok());
2004                    serde_json::json!({
2005                        "project_label": e.project_label,
2006                        "timestamp": fmt_la_time(e.timestamp_utc),
2007                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2008                        "config": config_val,
2009                    })
2010                })
2011                .collect()
2012        };
2013        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2014    };
2015
2016    let template = ScanSetupTemplate {
2017        version: env!("CARGO_PKG_VERSION"),
2018        recent_scans_json,
2019        csp_nonce,
2020    };
2021    Html(
2022        template
2023            .render()
2024            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2025    )
2026}
2027
2028async fn healthz() -> &'static str {
2029    "ok"
2030}
2031
2032async fn api_version_handler() -> impl IntoResponse {
2033    axum::Json(serde_json::json!({
2034        "name": "oxide-sloc",
2035        "version": env!("CARGO_PKG_VERSION"),
2036    }))
2037}
2038
2039// ── Prometheus metrics ────────────────────────────────────────────────────────
2040
2041fn prom_runs_total() -> &'static prometheus::IntCounter {
2042    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2043    COUNTER.get_or_init(|| {
2044        prometheus::register_int_counter!(
2045            "oxide_sloc_runs_total",
2046            "Total number of completed analysis runs"
2047        )
2048        .expect("failed to register oxide_sloc_runs_total counter")
2049    })
2050}
2051
2052async fn metrics_handler() -> impl IntoResponse {
2053    use prometheus::Encoder as _;
2054    let mut buf = Vec::new();
2055    let encoder = prometheus::TextEncoder::new();
2056    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2057    (
2058        [(
2059            axum::http::header::CONTENT_TYPE,
2060            "text/plain; version=0.0.4; charset=utf-8",
2061        )],
2062        buf,
2063    )
2064}
2065
2066static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2067
2068async fn openapi_yaml_handler() -> impl IntoResponse {
2069    (
2070        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2071        OPENAPI_YAML,
2072    )
2073}
2074
2075static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2076static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2077
2078async fn llms_txt_handler() -> impl IntoResponse {
2079    (
2080        [
2081            (
2082                axum::http::header::CONTENT_TYPE,
2083                "text/plain; charset=utf-8",
2084            ),
2085            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2086        ],
2087        LLMS_TXT,
2088    )
2089}
2090
2091async fn llms_full_txt_handler() -> impl IntoResponse {
2092    (
2093        [
2094            (
2095                axum::http::header::CONTENT_TYPE,
2096                "text/plain; charset=utf-8",
2097            ),
2098            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2099        ],
2100        LLMS_FULL_TXT,
2101    )
2102}
2103
2104async fn api_docs_handler(
2105    State(state): State<AppState>,
2106    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2107) -> impl IntoResponse {
2108    let has_api_key = !state.api_keys.is_empty();
2109    Html(
2110        ApiDocsTemplate {
2111            has_api_key,
2112            csp_nonce,
2113            version: env!("CARGO_PKG_VERSION"),
2114        }
2115        .render()
2116        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2117    )
2118}
2119
2120async fn chart_js_handler() -> impl IntoResponse {
2121    (
2122        [
2123            (
2124                header::CONTENT_TYPE,
2125                "application/javascript; charset=utf-8",
2126            ),
2127            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2128        ],
2129        CHART_JS,
2130    )
2131}
2132
2133async fn report_chart_js_handler() -> impl IntoResponse {
2134    (
2135        [
2136            (
2137                header::CONTENT_TYPE,
2138                "application/javascript; charset=utf-8",
2139            ),
2140            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2141        ],
2142        REPORT_CHART_JS,
2143    )
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct AnalyzeForm {
2148    path: String,
2149    git_repo: Option<String>,
2150    git_ref: Option<String>,
2151    mixed_line_policy: Option<MixedLinePolicy>,
2152    python_docstrings_as_comments: Option<String>,
2153    generated_file_detection: Option<String>,
2154    minified_file_detection: Option<String>,
2155    vendor_directory_detection: Option<String>,
2156    include_lockfiles: Option<String>,
2157    binary_file_behavior: Option<BinaryFileBehavior>,
2158    output_dir: Option<String>,
2159    report_title: Option<String>,
2160    report_header_footer: Option<String>,
2161    include_globs: Option<String>,
2162    exclude_globs: Option<String>,
2163    submodule_breakdown: Option<String>,
2164    coverage_file: Option<String>,
2165    continuation_line_policy: Option<ContinuationLinePolicy>,
2166    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2167    count_compiler_directives: Option<String>,
2168    style_col_threshold: Option<String>,
2169    style_analysis_enabled: Option<String>,
2170    style_score_threshold: Option<String>,
2171    style_lang_scope: Option<String>,
2172    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2173    cocomo_mode: Option<String>,
2174    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2175    complexity_alert: Option<String>,
2176    /// Whether to exclude duplicate files from displayed SLOC totals.
2177    exclude_duplicates: Option<String>,
2178    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2179    activity_window: Option<String>,
2180}
2181
2182#[allow(clippy::struct_excessive_bools)]
2183#[derive(Debug, Serialize, Deserialize, Clone)]
2184struct ScanConfig {
2185    oxide_sloc_version: String,
2186    path: String,
2187    include_globs: String,
2188    exclude_globs: String,
2189    submodule_breakdown: bool,
2190    mixed_line_policy: String,
2191    python_docstrings_as_comments: bool,
2192    generated_file_detection: bool,
2193    minified_file_detection: bool,
2194    vendor_directory_detection: bool,
2195    include_lockfiles: bool,
2196    binary_file_behavior: String,
2197    output_dir: String,
2198    report_title: String,
2199    // IEEE 1045-1992 and advanced fields added in later release
2200    #[serde(default = "default_each_physical_line")]
2201    continuation_line_policy: String,
2202    #[serde(default = "default_count_as_comment")]
2203    blank_in_block_comment_policy: String,
2204    #[serde(default = "default_true_bool")]
2205    count_compiler_directives: bool,
2206    #[serde(default = "default_true_bool")]
2207    style_analysis_enabled: bool,
2208    #[serde(default = "default_style_col_threshold")]
2209    style_col_threshold: u16,
2210    #[serde(default)]
2211    style_score_threshold: u8,
2212    #[serde(default = "default_all_scope")]
2213    style_lang_scope: String,
2214    #[serde(default)]
2215    coverage_file: String,
2216    #[serde(default = "default_organic")]
2217    cocomo_mode: String,
2218    #[serde(default)]
2219    complexity_alert: u32,
2220    #[serde(default)]
2221    exclude_duplicates: bool,
2222    /// Git hotspots activity window in days (on by default; 0 = disabled).
2223    #[serde(default = "default_activity_window")]
2224    activity_window: u32,
2225}
2226
2227const fn default_activity_window() -> u32 {
2228    90
2229}
2230
2231fn default_each_physical_line() -> String {
2232    "each_physical_line".to_string()
2233}
2234fn default_count_as_comment() -> String {
2235    "count_as_comment".to_string()
2236}
2237const fn default_true_bool() -> bool {
2238    true
2239}
2240const fn default_style_col_threshold() -> u16 {
2241    80
2242}
2243fn default_all_scope() -> String {
2244    "all".to_string()
2245}
2246fn default_organic() -> String {
2247    "organic".to_string()
2248}
2249
2250#[derive(Debug, Deserialize, Default)]
2251struct IndexQuery {
2252    path: Option<String>,
2253    include_globs: Option<String>,
2254    exclude_globs: Option<String>,
2255    submodule_breakdown: Option<String>,
2256    mixed_line_policy: Option<String>,
2257    python_docstrings_as_comments: Option<String>,
2258    generated_file_detection: Option<String>,
2259    minified_file_detection: Option<String>,
2260    vendor_directory_detection: Option<String>,
2261    include_lockfiles: Option<String>,
2262    binary_file_behavior: Option<String>,
2263    output_dir: Option<String>,
2264    report_title: Option<String>,
2265    prefilled: Option<String>,
2266    git_repo: Option<String>,
2267    git_ref: Option<String>,
2268    // IEEE 1045-1992 and advanced fields
2269    continuation_line_policy: Option<String>,
2270    blank_in_block_comment_policy: Option<String>,
2271    count_compiler_directives: Option<String>,
2272    style_analysis_enabled: Option<String>,
2273    style_col_threshold: Option<String>,
2274    style_score_threshold: Option<String>,
2275    style_lang_scope: Option<String>,
2276    coverage_file: Option<String>,
2277    cocomo_mode: Option<String>,
2278    complexity_alert: Option<String>,
2279    exclude_duplicates: Option<String>,
2280    activity_window: Option<String>,
2281}
2282
2283#[derive(Debug, Deserialize)]
2284struct PreviewQuery {
2285    path: Option<String>,
2286    include_globs: Option<String>,
2287    exclude_globs: Option<String>,
2288}
2289
2290#[cfg(feature = "native-dialog")]
2291#[derive(Debug, Deserialize)]
2292struct PickDirectoryQuery {
2293    kind: Option<String>,
2294    current: Option<String>,
2295}
2296
2297#[cfg(not(feature = "native-dialog"))]
2298#[derive(Debug, Deserialize)]
2299struct PickDirectoryQuery {}
2300
2301#[derive(Debug, Deserialize, Default)]
2302struct ArtifactQuery {
2303    download: Option<String>,
2304}
2305
2306#[cfg(feature = "native-dialog")]
2307#[derive(Debug, Serialize)]
2308struct PickDirectoryResponse {
2309    selected_path: Option<String>,
2310    cancelled: bool,
2311}
2312
2313#[cfg(feature = "native-dialog")]
2314async fn pick_directory_handler(
2315    State(state): State<AppState>,
2316    Query(query): Query<PickDirectoryQuery>,
2317) -> Response {
2318    if state.server_mode {
2319        return StatusCode::NOT_FOUND.into_response();
2320    }
2321    // Return immediately without opening a dialog in headless / CI environments.
2322    if std::env::var("SLOC_HEADLESS").is_ok() {
2323        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2324            .into_response();
2325    }
2326
2327    let is_coverage = query.kind.as_deref() == Some("coverage");
2328    let title = match query.kind.as_deref() {
2329        Some("output") => "Select output directory",
2330        Some("reports") => "Select folder containing saved reports",
2331        Some("coverage") => "Select LCOV coverage file",
2332        _ => "Select project directory",
2333    }
2334    .to_owned();
2335    let current = query.current.clone();
2336
2337    let picked = tokio::task::spawn_blocking(move || {
2338        // Windows: attach to the foreground thread so the dialog inherits focus,
2339        // and kick off a watcher that flashes the dialog once it appears.
2340        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2341        let fg_tid = win_dialog_focus::attach_to_foreground();
2342        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2343        win_dialog_focus::flash_dialog_when_ready(title.clone());
2344
2345        let mut dialog = rfd::FileDialog::new().set_title(&title);
2346        if let Some(current) = current.as_deref() {
2347            let resolved = resolve_input_path(current);
2348            let seed = if resolved.is_dir() {
2349                Some(resolved)
2350            } else {
2351                resolved.parent().map(Path::to_path_buf)
2352            };
2353            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2354                dialog = dialog.set_directory(seed_dir);
2355            }
2356        }
2357        let result = if is_coverage {
2358            dialog
2359                .add_filter(
2360                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2361                    &["info", "lcov", "xml", "json"],
2362                )
2363                .pick_file()
2364        } else {
2365            dialog.pick_folder()
2366        };
2367
2368        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2369        win_dialog_focus::detach_from_foreground(fg_tid);
2370
2371        result
2372    })
2373    .await
2374    .unwrap_or(None);
2375
2376    Json(PickDirectoryResponse {
2377        selected_path: picked.as_ref().map(|p| display_path(p)),
2378        cancelled: picked.is_none(),
2379    })
2380    .into_response()
2381}
2382
2383#[cfg(not(feature = "native-dialog"))]
2384async fn pick_directory_handler(
2385    State(_state): State<AppState>,
2386    Query(_query): Query<PickDirectoryQuery>,
2387) -> Response {
2388    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2389}
2390
2391#[cfg(feature = "native-dialog")]
2392async fn pick_file_handler(State(state): State<AppState>) -> Response {
2393    if state.server_mode {
2394        return StatusCode::NOT_FOUND.into_response();
2395    }
2396    if std::env::var("SLOC_HEADLESS").is_ok() {
2397        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2398            .into_response();
2399    }
2400    let picked = tokio::task::spawn_blocking(|| {
2401        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2402        let fg_tid = win_dialog_focus::attach_to_foreground();
2403        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2404        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2405
2406        let result = rfd::FileDialog::new()
2407            .set_title("Select HTML report")
2408            .add_filter("HTML report", &["html"])
2409            .pick_file();
2410
2411        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2412        win_dialog_focus::detach_from_foreground(fg_tid);
2413
2414        result
2415    })
2416    .await
2417    .unwrap_or(None);
2418    Json(PickDirectoryResponse {
2419        selected_path: picked.as_ref().map(|p| display_path(p)),
2420        cancelled: picked.is_none(),
2421    })
2422    .into_response()
2423}
2424
2425#[cfg(not(feature = "native-dialog"))]
2426async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2427    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2428}
2429
2430// ── Browser-upload handlers (server mode only) ────────────────────────────────
2431
2432/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2433/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2434fn is_upload_tmp_path(path: &Path) -> bool {
2435    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2436    path.starts_with(&upload_root)
2437}
2438
2439/// Returns true when `path` is the built-in sample or test-fixture directory.
2440/// These paths ship with the server binary and are always safe to scan/preview.
2441fn is_sample_path(path: &Path) -> bool {
2442    let root = workspace_root();
2443    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2444}
2445
2446/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2447fn upload_base_dir() -> PathBuf {
2448    std::env::temp_dir().join("oxide-sloc-uploads")
2449}
2450
2451/// Returns the staging path for a given upload id inside the base dir.
2452fn upload_staging_path(id: &str) -> PathBuf {
2453    upload_base_dir().join(id)
2454}
2455
2456/// Validate basic field constraints on a directory-upload request.
2457/// Returns an error `Response` if the request should be rejected immediately.
2458#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2459fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2460    const MAX_FILES: usize = 50_000;
2461    if body.files.is_empty() {
2462        return Err((
2463            StatusCode::BAD_REQUEST,
2464            Json(serde_json::json!({"error": "No files received"})),
2465        )
2466            .into_response());
2467    }
2468    if body.files.len() > MAX_FILES {
2469        return Err((
2470            StatusCode::PAYLOAD_TOO_LARGE,
2471            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2472        )
2473            .into_response());
2474    }
2475    Ok(())
2476}
2477
2478/// Resolve or create the staging directory for a directory upload.
2479/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2480fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2481    match id {
2482        Some(id)
2483            if !id.is_empty()
2484                && id.len() <= 36
2485                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2486        {
2487            (id.to_string(), upload_staging_path(id))
2488        }
2489        _ => {
2490            let new_id = uuid::Uuid::new_v4().to_string();
2491            let staging = upload_staging_path(&new_id);
2492            (new_id, staging)
2493        }
2494    }
2495}
2496
2497/// Decode, size-check, and write one uploaded file entry into `staging`.
2498/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2499/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2500/// cleaning up `staging` before propagating the error.
2501#[allow(clippy::result_large_err)]
2502async fn stage_decoded_entry(
2503    entry: &UploadedFile,
2504    staging: &Path,
2505    total_bytes: &mut usize,
2506    project_root: &mut Option<PathBuf>,
2507) -> Result<(), Response> {
2508    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2509
2510    let Ok(data) = base64::Engine::decode(
2511        &base64::engine::general_purpose::STANDARD,
2512        entry.content.as_bytes(),
2513    ) else {
2514        return Ok(());
2515    };
2516
2517    *total_bytes += data.len();
2518    if *total_bytes > MAX_TOTAL_BYTES {
2519        return Err((
2520            StatusCode::PAYLOAD_TOO_LARGE,
2521            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2522        )
2523            .into_response());
2524    }
2525
2526    let rel = std::path::Path::new(&entry.path);
2527    if project_root.is_none() {
2528        if let Some(first) = rel.components().next() {
2529            *project_root = Some(staging.join(first.as_os_str()));
2530        }
2531    }
2532
2533    let dest = staging.join(rel);
2534    if let Some(parent) = dest.parent() {
2535        if tokio::fs::create_dir_all(parent).await.is_err() {
2536            return Err((
2537                StatusCode::INTERNAL_SERVER_ERROR,
2538                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2539            )
2540                .into_response());
2541        }
2542    }
2543
2544    if tokio::fs::write(&dest, &data).await.is_err() {
2545        return Err((
2546            StatusCode::INTERNAL_SERVER_ERROR,
2547            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2548        )
2549            .into_response());
2550    }
2551
2552    Ok(())
2553}
2554
2555/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2556/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2557/// an error `Response` on failure (staging dir is cleaned up before returning).
2558async fn write_upload_files(
2559    files: &[UploadedFile],
2560    staging: &Path,
2561    upload_id: &str,
2562) -> Result<(usize, Option<PathBuf>), Response> {
2563    let mut total_bytes: usize = 0;
2564    let mut project_root: Option<PathBuf> = None;
2565
2566    for entry in files {
2567        let rel = std::path::Path::new(&entry.path);
2568        if rel
2569            .components()
2570            .any(|c| matches!(c, std::path::Component::ParentDir))
2571        {
2572            // Reject the entire upload on the first path traversal attempt.
2573            let _ = tokio::fs::remove_dir_all(staging).await;
2574            tracing::warn!(
2575                event = "upload_path_traversal",
2576                upload_id = %upload_id,
2577                path = %entry.path,
2578                "Upload rejected: path traversal component detected"
2579            );
2580            return Err((
2581                StatusCode::BAD_REQUEST,
2582                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2583            )
2584                .into_response());
2585        }
2586
2587        if let Err(resp) =
2588            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2589        {
2590            let _ = tokio::fs::remove_dir_all(staging).await;
2591            return Err(resp);
2592        }
2593    }
2594
2595    Ok((files.len(), project_root))
2596}
2597
2598/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2599/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2600fn parse_tarball_size_caps() -> (u64, u64) {
2601    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2602        .ok()
2603        .and_then(|v| v.parse().ok())
2604        .unwrap_or(2048_u64)
2605        * 1024
2606        * 1024;
2607    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2608        .ok()
2609        .and_then(|v| v.parse().ok())
2610        .unwrap_or(10_240_u64)
2611        * 1024
2612        * 1024;
2613    (compressed, decompressed)
2614}
2615
2616/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
2617/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
2618/// are rejected before the streaming handler is invoked.
2619fn tarball_http_body_limit_bytes() -> usize {
2620    std::env::var("SLOC_MAX_TARBALL_MB")
2621        .ok()
2622        .and_then(|v| v.parse::<usize>().ok())
2623        .unwrap_or(2048)
2624        .saturating_mul(1024 * 1024)
2625}
2626
2627/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2628/// Returns the number of compressed bytes written, or an error `Response`.
2629/// Cleans up `dest_path` on error.
2630#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2631async fn stream_body_to_file(
2632    body: axum::body::Body,
2633    dest_path: &Path,
2634    max_bytes: u64,
2635) -> Result<u64, Response> {
2636    use http_body_util::BodyExt as _;
2637    use tokio::io::AsyncWriteExt as _;
2638
2639    let mut file = match tokio::fs::File::create(dest_path).await {
2640        Ok(f) => f,
2641        Err(e) => {
2642            tracing::error!(
2643                event = "upload_io_error",
2644                "failed to create tarball temp file: {e}"
2645            );
2646            return Err((
2647                StatusCode::INTERNAL_SERVER_ERROR,
2648                Json(serde_json::json!({"error": "Upload initialization failed"})),
2649            )
2650                .into_response());
2651        }
2652    };
2653
2654    let mut body = body;
2655    let mut written: u64 = 0;
2656    loop {
2657        match body.frame().await {
2658            None => break,
2659            Some(Err(e)) => {
2660                let _ = tokio::fs::remove_file(dest_path).await;
2661                return Err((
2662                    StatusCode::BAD_REQUEST,
2663                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2664                )
2665                    .into_response());
2666            }
2667            Some(Ok(frame)) => {
2668                if let Ok(data) = frame.into_data() {
2669                    written += data.len() as u64;
2670                    if written > max_bytes {
2671                        let _ = tokio::fs::remove_file(dest_path).await;
2672                        return Err((
2673                            StatusCode::PAYLOAD_TOO_LARGE,
2674                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2675                        )
2676                            .into_response());
2677                    }
2678                    if let Err(e) = file.write_all(&data).await {
2679                        let _ = tokio::fs::remove_file(dest_path).await;
2680                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2681                        return Err((
2682                            StatusCode::INTERNAL_SERVER_ERROR,
2683                            Json(serde_json::json!({"error": "Upload write failed"})),
2684                        )
2685                            .into_response());
2686                    }
2687                }
2688            }
2689        }
2690    }
2691    drop(file);
2692    Ok(written)
2693}
2694
2695/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2696/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2697/// on failure (staging dir is cleaned up before returning).
2698#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2699async fn extract_tarball_to_staging(
2700    tarball_path: &Path,
2701    staging: &Path,
2702    max_decompressed_bytes: u64,
2703) -> Result<(), Response> {
2704    let staging_clone = staging.to_path_buf();
2705    let tarball_clone = tarball_path.to_path_buf();
2706    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2707        let file = std::fs::File::open(&tarball_clone)?;
2708        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2709        let limited = SizeLimitReader {
2710            inner: gz,
2711            remaining: max_decompressed_bytes,
2712        };
2713        let mut archive = tar::Archive::new(limited);
2714        archive.set_overwrite(true);
2715        archive.set_preserve_permissions(false);
2716        std::fs::create_dir_all(&staging_clone)?;
2717        archive.unpack(&staging_clone)?;
2718        Ok(())
2719    })
2720    .await;
2721    let _ = tokio::fs::remove_file(tarball_path).await;
2722
2723    match extract_result {
2724        Ok(Ok(())) => Ok(()),
2725        Ok(Err(e)) => {
2726            let _ = tokio::fs::remove_dir_all(staging).await;
2727            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2728            tracing::warn!(
2729                event = "upload_extract_error",
2730                "tarball extraction failed: {e:#}"
2731            );
2732            let (status, msg) = if is_size_limit {
2733                (
2734                    StatusCode::PAYLOAD_TOO_LARGE,
2735                    "Archive exceeds the decompressed size limit",
2736                )
2737            } else {
2738                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2739            };
2740            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2741        }
2742        Err(e) => {
2743            let _ = tokio::fs::remove_dir_all(staging).await;
2744            tracing::error!(
2745                event = "upload_extract_panic",
2746                "tarball extraction task panicked: {e}"
2747            );
2748            Err((
2749                StatusCode::INTERNAL_SERVER_ERROR,
2750                Json(serde_json::json!({"error": "Archive extraction failed"})),
2751            )
2752                .into_response())
2753        }
2754    }
2755}
2756
2757/// If `staging` contains exactly one top-level directory, return its path
2758/// (the common case when the archive was created with `webkitRelativePath`).
2759/// Otherwise return `None`.
2760async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2761    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2762    let first = entries.next_entry().await.ok()??;
2763    if !first.path().is_dir() {
2764        return None;
2765    }
2766    if entries.next_entry().await.unwrap_or(None).is_some() {
2767        return None;
2768    }
2769    Some(first.path())
2770}
2771
2772/// Request body for `POST /api/upload-directory`.
2773///
2774/// Each entry carries a relative path (identical to the browser's
2775/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2776/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2777/// avoids pulling in a `multipart` library that is not in the vendor archive.
2778#[derive(Deserialize)]
2779struct UploadDirRequest {
2780    files: Vec<UploadedFile>,
2781    /// If provided, append this batch to an existing upload session instead of
2782    /// creating a new staging directory. Must be a plain UUID (no path separators).
2783    upload_id: Option<String>,
2784}
2785
2786#[derive(Deserialize)]
2787struct UploadedFile {
2788    /// `webkitRelativePath` value from the browser File object.
2789    path: String,
2790    /// Raw file bytes encoded as standard base64.
2791    content: String,
2792}
2793
2794/// POST /api/upload-directory
2795///
2796/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2797/// Saves all files to a temp staging directory preserving their relative paths,
2798/// then returns the server-side root directory path so the caller can populate
2799/// the scan-path field and run a normal analysis.
2800///
2801/// Only available in server mode; returns 404 in local mode (use the native
2802/// rfd dialog instead).
2803async fn upload_directory_handler(
2804    State(state): State<AppState>,
2805    Json(body): Json<UploadDirRequest>,
2806) -> Response {
2807    if !state.server_mode {
2808        return StatusCode::NOT_FOUND.into_response();
2809    }
2810    if let Err(resp) = validate_upload_dir_request(&body) {
2811        return resp;
2812    }
2813    // Reuse an existing staging dir when the client sends a continuation batch,
2814    // otherwise create a fresh one. Validate the id to prevent path traversal.
2815    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2816    match write_upload_files(&body.files, &staging, &upload_id).await {
2817        Ok((file_count, project_root)) => {
2818            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2819            Json(serde_json::json!({
2820                "tmp_path": scan_root.to_string_lossy(),
2821                "file_count": file_count,
2822                "upload_id": upload_id.clone()
2823            }))
2824            .into_response()
2825        }
2826        Err(resp) => resp,
2827    }
2828}
2829
2830/// Request body for `POST /api/upload-file`.
2831#[derive(Deserialize)]
2832struct UploadFileRequest {
2833    /// Original filename (used only to preserve the extension).
2834    filename: String,
2835    /// File bytes encoded as standard base64.
2836    content: String,
2837}
2838
2839/// POST /api/upload-file
2840///
2841/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2842/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2843/// Only available in server mode.
2844async fn upload_file_handler(
2845    State(state): State<AppState>,
2846    Json(body): Json<UploadFileRequest>,
2847) -> Response {
2848    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2849
2850    if !state.server_mode {
2851        return StatusCode::NOT_FOUND.into_response();
2852    }
2853
2854    let Ok(data) = base64::Engine::decode(
2855        &base64::engine::general_purpose::STANDARD,
2856        body.content.as_bytes(),
2857    ) else {
2858        return (
2859            StatusCode::BAD_REQUEST,
2860            Json(serde_json::json!({"error": "Invalid base64 content"})),
2861        )
2862            .into_response();
2863    };
2864
2865    if data.len() > MAX_FILE_BYTES {
2866        return (
2867            StatusCode::PAYLOAD_TOO_LARGE,
2868            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2869        )
2870            .into_response();
2871    }
2872
2873    // Sanitise: strip any directory component from the filename.
2874    let filename = std::path::Path::new(&body.filename)
2875        .file_name()
2876        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2877
2878    let upload_id = uuid::Uuid::new_v4();
2879    let staging = std::env::temp_dir()
2880        .join("oxide-sloc-uploads")
2881        .join(upload_id.to_string());
2882
2883    if tokio::fs::create_dir_all(&staging).await.is_err() {
2884        return (
2885            StatusCode::INTERNAL_SERVER_ERROR,
2886            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2887        )
2888            .into_response();
2889    }
2890
2891    let dest = staging.join(&filename);
2892    if tokio::fs::write(&dest, &data).await.is_err() {
2893        let _ = tokio::fs::remove_dir_all(&staging).await;
2894        return (
2895            StatusCode::INTERNAL_SERVER_ERROR,
2896            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2897        )
2898            .into_response();
2899    }
2900
2901    Json(serde_json::json!({
2902        "tmp_path": dest.to_string_lossy(),
2903        "upload_id": upload_id.to_string()
2904    }))
2905    .into_response()
2906}
2907
2908/// POST /api/upload-tarball
2909///
2910/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2911/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2912/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2913/// project root. The two size fields power the "Original / Compressed project size" display in the
2914/// web UI.
2915///
2916/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
2917/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
2918/// cap during decompression. The browser-side JS creates the archive one file at a time using
2919/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
2920/// project size.
2921/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2922/// decompressed. Wraps any `std::io::Read` source.
2923struct SizeLimitReader<R> {
2924    inner: R,
2925    remaining: u64,
2926}
2927impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2928    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2929        if self.remaining == 0 {
2930            return Err(std::io::Error::other("decompressed size limit exceeded"));
2931        }
2932        let n = self.inner.read(buf)?;
2933        self.remaining = self.remaining.saturating_sub(n as u64);
2934        Ok(n)
2935    }
2936}
2937
2938async fn upload_tarball_handler(
2939    State(state): State<AppState>,
2940    request: axum::extract::Request,
2941) -> Response {
2942    if !state.server_mode {
2943        return StatusCode::NOT_FOUND.into_response();
2944    }
2945
2946    let upload_id = uuid::Uuid::new_v4().to_string();
2947    let upload_base = upload_base_dir();
2948    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2949    let staging = upload_staging_path(&upload_id);
2950    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2951
2952    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2953        tracing::error!(
2954            event = "upload_io_error",
2955            "failed to create upload base dir: {e}"
2956        );
2957        return (
2958            StatusCode::INTERNAL_SERVER_ERROR,
2959            Json(serde_json::json!({"error": "Upload initialization failed"})),
2960        )
2961            .into_response();
2962    }
2963
2964    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2965    let compressed_bytes =
2966        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2967            Ok(n) => n,
2968            Err(resp) => return resp,
2969        };
2970
2971    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2972    if let Err(resp) =
2973        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2974    {
2975        return resp;
2976    }
2977
2978    // ── 3. Find the project root inside the staging dir ───────────────────────
2979    // If the tar contained a single top-level directory (the common case when the
2980    // browser uses `webkitRelativePath`), return that as the scan root so the path
2981    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
2982    let scan_root = find_single_top_dir(&staging)
2983        .await
2984        .unwrap_or_else(|| staging.clone());
2985
2986    // Compute original (uncompressed) size of the extracted tree.
2987    let original_bytes = tokio::task::spawn_blocking({
2988        let p = scan_root.clone();
2989        move || dir_size_bytes(&p)
2990    })
2991    .await
2992    .unwrap_or(0);
2993
2994    Json(serde_json::json!({
2995        "tmp_path": scan_root.to_string_lossy(),
2996        "upload_id": upload_id,
2997        "compressed_bytes": compressed_bytes,
2998        "original_bytes": original_bytes,
2999    }))
3000    .into_response()
3001}
3002
3003#[derive(Deserialize)]
3004struct LocateReportForm {
3005    file_path: String,
3006    #[serde(default)]
3007    redirect_url: Option<String>,
3008    #[serde(default)]
3009    expected_run_id: Option<String>,
3010}
3011
3012/// Render a view-reports error page and return it as a `Response`.
3013fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3014    let html = ErrorTemplate {
3015        message: message.into(),
3016        last_report_url: Some("/view-reports".to_string()),
3017        last_report_label: Some("View Reports".to_string()),
3018        run_id: None,
3019        error_code: None,
3020        csp_nonce: csp_nonce.to_owned(),
3021        version: env!("CARGO_PKG_VERSION"),
3022    }
3023    .render()
3024    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3025    Html(html).into_response()
3026}
3027
3028/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3029fn registry_entry_from_run(
3030    run: &AnalysisRun,
3031    json_path: PathBuf,
3032    html_path: PathBuf,
3033) -> RegistryEntry {
3034    let project_label = run.input_roots.first().map_or_else(
3035        || "Unknown Project".to_string(),
3036        |r| sanitize_project_label(r),
3037    );
3038    RegistryEntry {
3039        run_id: run.tool.run_id.clone(),
3040        timestamp_utc: run.tool.timestamp_utc,
3041        project_label,
3042        input_roots: run.input_roots.clone(),
3043        json_path: Some(json_path),
3044        html_path: Some(html_path),
3045        pdf_path: None,
3046        summary: ScanSummarySnapshot::from(&run.summary_totals),
3047        csv_path: None,
3048        xlsx_path: None,
3049        git_branch: None,
3050        git_commit: None,
3051        git_commit_long: None,
3052        git_author: None,
3053        git_tags: None,
3054        git_nearest_tag: None,
3055        git_commit_date: None,
3056    }
3057}
3058
3059/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3060/// immediately without requiring a server restart.
3061pub(crate) async fn register_artifacts_in_registry(
3062    state: &AppState,
3063    label: &str,
3064    run: &AnalysisRun,
3065    artifacts: &RunArtifacts,
3066) {
3067    let Some(json_path) = artifacts.json_path.clone() else {
3068        return;
3069    };
3070    let Some(html_path) = artifacts.html_path.clone() else {
3071        return;
3072    };
3073    let mut entry = registry_entry_from_run(run, json_path, html_path);
3074    entry.project_label = label.to_owned();
3075    let mut reg = state.registry.lock().await;
3076    reg.add_entry(entry);
3077    let _ = reg.save(&state.registry_path);
3078}
3079
3080fn is_html_report_file(p: &Path) -> bool {
3081    p.is_file()
3082        && p.extension()
3083            .and_then(|x| x.to_str())
3084            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3085        && p.file_name()
3086            .and_then(|n| n.to_str())
3087            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3088}
3089
3090fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3091    fs::read_dir(dir)
3092        .ok()?
3093        .flatten()
3094        .map(|e| e.path())
3095        .find(|p| is_html_report_file(p))
3096}
3097
3098fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3099    if let Some(f) = find_html_report_in_dir(dir) {
3100        return Some(f);
3101    }
3102    if let Ok(rd) = fs::read_dir(dir) {
3103        for entry in rd.flatten() {
3104            let sub = entry.path();
3105            if sub.is_dir() {
3106                if let Some(f) = find_html_report_in_dir(&sub) {
3107                    return Some(f);
3108                }
3109            }
3110        }
3111    }
3112    None
3113}
3114
3115/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3116/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3117///
3118/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3119#[allow(clippy::result_large_err)]
3120fn validate_locate_request(
3121    state: &AppState,
3122    file_path: &str,
3123    csp_nonce: &str,
3124) -> Result<(PathBuf, PathBuf), Response> {
3125    let raw = PathBuf::from(file_path);
3126
3127    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3128    let html_path = if raw.is_dir() {
3129        let found = find_html_report_in_tree(&raw);
3130        match found {
3131            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3132            None => {
3133                return Err(locate_report_error(
3134                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3135                     the folder that contains your scan output (result_*.html or report_*.html).",
3136                    csp_nonce,
3137                ));
3138            }
3139        }
3140    } else {
3141        let file_ext = raw
3142            .extension()
3143            .and_then(|e| e.to_str())
3144            .unwrap_or("")
3145            .to_ascii_lowercase();
3146        if file_ext != "html" {
3147            return Err(locate_report_error(
3148                "Please select the scan output folder, or an .html report file directly.",
3149                csp_nonce,
3150            ));
3151        }
3152        match fs::canonicalize(&raw) {
3153            Ok(p) => strip_unc_prefix(p),
3154            Err(_) => {
3155                return Err(locate_report_error(
3156                    "Report file not found or path is invalid.",
3157                    csp_nonce,
3158                ));
3159            }
3160        }
3161    };
3162
3163    if state.server_mode {
3164        let output_root = resolve_output_root(None);
3165        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3166        if !html_path.starts_with(&canonical_root) {
3167            return Err(locate_report_error(
3168                "Report file must be within the configured output directory.",
3169                csp_nonce,
3170            ));
3171        }
3172    }
3173    let parent = match html_path.parent() {
3174        Some(p) => p.to_path_buf(),
3175        None => {
3176            return Err(locate_report_error(
3177                "Report file has no parent directory.",
3178                csp_nonce,
3179            ));
3180        }
3181    };
3182    Ok((html_path, parent))
3183}
3184
3185/// JSON-or-HTML error for `locate_report_handler` error paths.
3186fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3187    if want_json {
3188        (
3189            StatusCode::UNPROCESSABLE_ENTITY,
3190            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3191        )
3192            .into_response()
3193    } else {
3194        locate_report_error(msg, csp_nonce)
3195    }
3196}
3197
3198/// JSON-or-redirect success for locate/relocate handler success paths.
3199fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3200    if want_json {
3201        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3202    } else {
3203        axum::response::Redirect::to(redirect).into_response()
3204    }
3205}
3206
3207/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3208/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3209fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3210    for jpath in candidates {
3211        if let Ok(run) = read_json(jpath) {
3212            if expected.is_empty() || run.tool.run_id == expected {
3213                return Some((jpath.clone(), run.tool.run_id));
3214            }
3215        }
3216    }
3217    None
3218}
3219
3220fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3221    html_path
3222        .parent()
3223        .and_then(|p| p.parent())
3224        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3225}
3226
3227fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3228    let mut hits = collect_result_json_candidates(scan_root);
3229    if hits.is_empty() {
3230        hits = collect_result_json_candidates(parent);
3231    }
3232    hits.sort();
3233    hits
3234}
3235
3236#[allow(clippy::too_many_lines)]
3237async fn locate_report_handler(
3238    State(state): State<AppState>,
3239    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3240    headers: axum::http::HeaderMap,
3241    Form(form): Form<LocateReportForm>,
3242) -> impl IntoResponse {
3243    let want_json = headers
3244        .get(axum::http::header::ACCEPT)
3245        .and_then(|v| v.to_str().ok())
3246        .is_some_and(|v| v.contains("application/json"));
3247
3248    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3249        Ok(v) => v,
3250        Err(resp) => {
3251            if want_json {
3252                return locate_handler_err(
3253                    true,
3254                    "No HTML report file found in the selected folder. \
3255                     Make sure you selected the folder that contains your \
3256                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3257                        .to_string(),
3258                    &csp_nonce,
3259                );
3260            }
3261            return resp;
3262        }
3263    };
3264
3265    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3266    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3267    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3268    let scan_root: &Path = &scan_root_owned;
3269    let json_candidates = gather_json_candidates(scan_root, &parent);
3270
3271    // If the expected_run_id was provided, find a JSON that matches it exactly.
3272    let expected_run_id = form
3273        .expected_run_id
3274        .as_deref()
3275        .unwrap_or("")
3276        .trim()
3277        .to_string();
3278
3279    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3280
3281    // If we have candidates but none matched the expected run_id, surface a clear error.
3282    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3283        let actual = json_candidates
3284            .iter()
3285            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3286            .unwrap_or_else(|| "unknown".to_string());
3287        return locate_handler_err(
3288            want_json,
3289            format!(
3290                "This folder contains a different scan.\n\n\
3291                 Expected run ID : {expected_run_id}\n\
3292                 Found run ID    : {actual}\n\n\
3293                 Please select the folder that contains the correct scan output."
3294            ),
3295            &csp_nonce,
3296        );
3297    }
3298
3299    let safe_redirect = form
3300        .redirect_url
3301        .as_deref()
3302        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3303        .unwrap_or("/view-reports?linked=1")
3304        .to_string();
3305
3306    let mut reg = state.registry.lock().await;
3307
3308    if let Some((json_path, run_id)) = matched_json {
3309        // Match by run_id in the registry (works even after files are moved).
3310        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3311            entry.html_path = Some(html_path);
3312            entry.json_path = Some(json_path);
3313            let _ = reg.save(&state.registry_path);
3314            drop(reg);
3315            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3316            state.artifacts.lock().await.remove(&run_id);
3317            return redirect_or_json_ok(want_json, &safe_redirect);
3318        }
3319        // No existing entry — build one from the JSON.
3320        match read_json(&json_path) {
3321            Ok(run) => {
3322                let entry = registry_entry_from_run(&run, json_path, html_path);
3323                reg.add_entry(entry);
3324                let _ = reg.save(&state.registry_path);
3325                drop(reg);
3326                state.artifacts.lock().await.remove(&run_id);
3327                return redirect_or_json_ok(want_json, &safe_redirect);
3328            }
3329            Err(e) => {
3330                drop(reg);
3331                return locate_handler_err(
3332                    want_json,
3333                    format!(
3334                        "Found the scan folder but could not parse the result JSON.\n\n\
3335                         The file may have been saved by an older version of OxideSLOC. \
3336                         Re-running the analysis will create a fresh, compatible record.\n\n\
3337                         Error: {e}"
3338                    ),
3339                    &csp_nonce,
3340                );
3341            }
3342        }
3343    }
3344
3345    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3346    if let Some(entry) = reg
3347        .entries
3348        .iter_mut()
3349        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3350    {
3351        entry.html_path = Some(html_path.clone());
3352        let _ = reg.save(&state.registry_path);
3353        drop(reg);
3354        state.artifacts.lock().await.remove(&expected_run_id);
3355        return redirect_or_json_ok(want_json, &safe_redirect);
3356    }
3357
3358    drop(reg);
3359    let hint = if state.server_mode {
3360        String::new()
3361    } else {
3362        format!(
3363            "\n\nSearched folder : {}\nHTML found      : {}",
3364            scan_root.display(),
3365            html_path.display()
3366        )
3367    };
3368    locate_handler_err(
3369        want_json,
3370        format!(
3371            "Could not link this report.\n\n\
3372             No result_*.json was found in the selected folder. \
3373             Make sure you selected the top-level scan output folder \
3374             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3375        ),
3376        &csp_nonce,
3377    )
3378}
3379
3380/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3381fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3382    fs::read_dir(dir)
3383        .ok()?
3384        .flatten()
3385        .map(|e| e.path())
3386        .find(|p| {
3387            p.is_file()
3388                && p.file_stem()
3389                    .and_then(|n| n.to_str())
3390                    .is_some_and(|n| n.starts_with("result"))
3391                && p.extension()
3392                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3393        })
3394}
3395
3396#[derive(Deserialize)]
3397struct LocateReportsDirForm {
3398    folder_path: String,
3399}
3400
3401#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3402async fn locate_reports_dir_handler(
3403    State(state): State<AppState>,
3404    Form(form): Form<LocateReportsDirForm>,
3405) -> impl IntoResponse {
3406    if state.server_mode {
3407        return StatusCode::NOT_FOUND.into_response();
3408    }
3409    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3410        Ok(p) => strip_unc_prefix(p),
3411        Err(_) => {
3412            return axum::response::Redirect::to(
3413                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3414            )
3415            .into_response();
3416        }
3417    };
3418    if !folder.is_dir() {
3419        return axum::response::Redirect::to(
3420            "/view-reports?error=Selected+path+is+not+a+directory.",
3421        )
3422        .into_response();
3423    }
3424
3425    let candidates = collect_result_json_candidates(&folder);
3426
3427    if candidates.is_empty() {
3428        return axum::response::Redirect::to(
3429            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3430        )
3431        .into_response();
3432    }
3433
3434    let mut linked_count: usize = 0;
3435    let mut reg = state.registry.lock().await;
3436    for json_path in candidates {
3437        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3438            continue;
3439        };
3440        if is_dir_already_registered(&reg, &parent) {
3441            continue;
3442        }
3443        let Some(entry) = build_registry_entry_from_json(json_path) else {
3444            continue;
3445        };
3446        reg.add_entry(entry);
3447        linked_count += 1;
3448    }
3449    let _ = reg.save(&state.registry_path);
3450    drop(reg);
3451
3452    if linked_count == 0 {
3453        return axum::response::Redirect::to(
3454            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3455        )
3456        .into_response();
3457    }
3458    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3459}
3460
3461#[derive(Deserialize)]
3462struct RelocateScanForm {
3463    run_id: String,
3464    folder_path: String,
3465    redirect_url: String,
3466}
3467
3468/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3469/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3470fn relocate_folder_err(
3471    want_json: bool,
3472    status: StatusCode,
3473    msg: &str,
3474    run_id: &str,
3475    folder_hint: &str,
3476    redirect_url: &str,
3477    csp_nonce: &str,
3478) -> Response {
3479    if want_json {
3480        (
3481            status,
3482            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3483        )
3484            .into_response()
3485    } else {
3486        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3487    }
3488}
3489
3490#[allow(clippy::too_many_lines)]
3491async fn relocate_scan_handler(
3492    State(state): State<AppState>,
3493    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3494    headers: axum::http::HeaderMap,
3495    Form(form): Form<RelocateScanForm>,
3496) -> impl IntoResponse {
3497    let want_json = headers
3498        .get(axum::http::header::ACCEPT)
3499        .and_then(|v| v.to_str().ok())
3500        .is_some_and(|v| v.contains("application/json"));
3501    if state.server_mode {
3502        return StatusCode::NOT_FOUND.into_response();
3503    }
3504
3505    let run_id = form.run_id.trim().to_string();
3506    let redirect_url = form.redirect_url.trim().to_string();
3507
3508    let run_exists = {
3509        let reg = state.registry.lock().await;
3510        reg.find_by_run_id(&run_id).is_some()
3511    };
3512    if !run_exists {
3513        if want_json {
3514            return (
3515                StatusCode::NOT_FOUND,
3516                axum::Json(serde_json::json!({
3517                    "ok": false,
3518                    "message": format!("Run ID '{run_id}' not found in registry.")
3519                })),
3520            )
3521                .into_response();
3522        }
3523        let html = ErrorTemplate {
3524            message: format!("Run ID '{run_id}' not found in registry."),
3525            last_report_url: Some("/compare-scans".to_string()),
3526            last_report_label: Some("Compare Scans".to_string()),
3527            run_id: Some(run_id.clone()),
3528            error_code: Some(404),
3529            csp_nonce: csp_nonce.clone(),
3530            version: env!("CARGO_PKG_VERSION"),
3531        }
3532        .render()
3533        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3534        return Html(html).into_response();
3535    }
3536
3537    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3538        Ok(p) => strip_unc_prefix(p),
3539        Err(_) => {
3540            return relocate_folder_err(
3541                want_json,
3542                StatusCode::UNPROCESSABLE_ENTITY,
3543                "Folder not found or path is invalid.",
3544                &run_id,
3545                form.folder_path.trim(),
3546                &redirect_url,
3547                &csp_nonce,
3548            );
3549        }
3550    };
3551    if !folder.is_dir() {
3552        return relocate_folder_err(
3553            want_json,
3554            StatusCode::UNPROCESSABLE_ENTITY,
3555            "Selected path is not a directory.",
3556            &run_id,
3557            &folder.display().to_string(),
3558            &redirect_url,
3559            &csp_nonce,
3560        );
3561    }
3562
3563    let json_candidates = find_result_files_by_ext(&folder, "json");
3564    if json_candidates.is_empty() {
3565        let msg = format!(
3566            "No result JSON files found in the selected folder.\nSearched: {}",
3567            folder.display()
3568        );
3569        return relocate_folder_err(
3570            want_json,
3571            StatusCode::UNPROCESSABLE_ENTITY,
3572            &msg,
3573            &run_id,
3574            &folder.display().to_string(),
3575            &redirect_url,
3576            &csp_nonce,
3577        );
3578    }
3579
3580    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3581        let msg = format!(
3582            "No matching scan found in the selected folder.\n\
3583             The JSON files present do not contain run ID: {run_id}\n\
3584             Searched: {}",
3585            folder.display()
3586        );
3587        return relocate_folder_err(
3588            want_json,
3589            StatusCode::UNPROCESSABLE_ENTITY,
3590            &msg,
3591            &run_id,
3592            &folder.display().to_string(),
3593            &redirect_url,
3594            &csp_nonce,
3595        );
3596    };
3597
3598    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3599    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3600    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3601
3602    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3603        redirect_url
3604    } else {
3605        "/compare-scans".to_string()
3606    };
3607    redirect_or_json_ok(want_json, &safe_redirect)
3608}
3609
3610fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3611    let mut out = Vec::new();
3612    collect_scan_files_by_ext(folder, ext, &mut out);
3613    if let Ok(rd) = fs::read_dir(folder) {
3614        for entry in rd.flatten() {
3615            let sub = entry.path();
3616            if sub.is_dir() {
3617                collect_scan_files_by_ext(&sub, ext, &mut out);
3618            }
3619        }
3620    }
3621    out
3622}
3623
3624fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3625    let Ok(rd) = fs::read_dir(dir) else { return };
3626    for entry in rd.flatten() {
3627        let p = entry.path();
3628        if p.is_file()
3629            && p.file_stem()
3630                .and_then(|n| n.to_str())
3631                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3632            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3633        {
3634            out.push(p);
3635        }
3636    }
3637}
3638
3639fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3640    candidates
3641        .iter()
3642        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3643        .cloned()
3644}
3645
3646/// Return the best folder hint for the relocate page.
3647/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
3648/// point at the parent — the actual top-level output directory — so the user
3649/// selects the root folder rather than the subfolder.
3650fn output_folder_hint(json_path: &std::path::Path) -> String {
3651    let Some(direct_parent) = json_path.parent() else {
3652        return String::new();
3653    };
3654    let parent_name = direct_parent
3655        .file_name()
3656        .and_then(|n| n.to_str())
3657        .unwrap_or("");
3658    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3659        direct_parent.parent().map_or_else(
3660            || direct_parent.display().to_string(),
3661            |p| p.display().to_string(),
3662        )
3663    } else {
3664        direct_parent.display().to_string()
3665    }
3666}
3667
3668async fn update_run_file_paths(
3669    state: &AppState,
3670    run_id: &str,
3671    json_path: PathBuf,
3672    html_path: Option<PathBuf>,
3673    pdf_path: Option<PathBuf>,
3674) {
3675    {
3676        let mut reg = state.registry.lock().await;
3677        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3678            entry.json_path = Some(json_path.clone());
3679            if let Some(ref hp) = html_path {
3680                entry.html_path = Some(hp.clone());
3681            }
3682            if let Some(ref pp) = pdf_path {
3683                entry.pdf_path = Some(pp.clone());
3684            }
3685        }
3686        let _ = reg.save(&state.registry_path);
3687    }
3688    // Also patch the in-memory artifacts map so the result page picks up the
3689    // new paths without requiring a server restart.
3690    {
3691        let mut map = state.artifacts.lock().await;
3692        if let Some(arts) = map.get_mut(run_id) {
3693            arts.json_path = Some(json_path);
3694            if let Some(hp) = html_path {
3695                arts.html_path = Some(hp);
3696            }
3697            if let Some(pp) = pdf_path {
3698                arts.pdf_path = Some(pp);
3699            }
3700        }
3701    }
3702}
3703
3704fn missing_scan_relocate_response(
3705    message: &str,
3706    run_id: &str,
3707    folder_hint: &str,
3708    redirect_url: &str,
3709    server_mode: bool,
3710    csp_nonce: &str,
3711) -> axum::response::Response {
3712    let html = RelocateScanTemplate {
3713        message: message.to_string(),
3714        run_id: run_id.to_string(),
3715        folder_hint: folder_hint.to_string(),
3716        redirect_url: redirect_url.to_string(),
3717        server_mode,
3718        csp_nonce: csp_nonce.to_owned(),
3719        version: env!("CARGO_PKG_VERSION"),
3720    }
3721    .render()
3722    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3723    (StatusCode::NOT_FOUND, Html(html)).into_response()
3724}
3725
3726// ── Watched-directory helpers ─────────────────────────────────────────────────
3727
3728/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3729fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3730    fs::read_dir(dir)
3731        .ok()?
3732        .flatten()
3733        .map(|e| e.path())
3734        .find(|p| {
3735            p.is_file()
3736                && p.extension()
3737                    .and_then(|e| e.to_str())
3738                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3739        })
3740}
3741
3742/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
3743/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
3744/// (`<scan_dir>/json/result*.json`).
3745fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3746    let mut out = Vec::new();
3747    if let Some(j) = find_result_json_in_dir(sub) {
3748        out.push(j);
3749    }
3750    let json_sub = sub.join("json");
3751    if json_sub.is_dir() {
3752        if let Some(j) = find_result_json_in_dir(&json_sub) {
3753            out.push(j);
3754        }
3755    }
3756    out
3757}
3758
3759fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3760    let mut candidates = Vec::new();
3761    if let Some(j) = find_result_json_in_dir(folder) {
3762        candidates.push(j);
3763    }
3764    let Ok(dir_entries) = fs::read_dir(folder) else {
3765        return candidates;
3766    };
3767    for entry in dir_entries.flatten() {
3768        let sub = entry.path();
3769        if sub.is_dir() {
3770            candidates.extend(subdir_result_json_candidates(&sub));
3771        }
3772    }
3773    candidates
3774}
3775
3776fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3777    reg.entries.iter().any(|e| {
3778        let dir_match = e
3779            .json_path
3780            .as_ref()
3781            .and_then(|p| p.parent())
3782            .is_some_and(|p| p == parent)
3783            || e.html_path
3784                .as_ref()
3785                .and_then(|p| p.parent())
3786                .is_some_and(|p| p == parent);
3787        dir_match
3788            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3789                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3790    })
3791}
3792
3793fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3794    let json_dir = json_path.parent()?.to_path_buf();
3795    // If the JSON lives inside a directory named "json", the scan root is its parent
3796    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
3797    let (html_path, pdf_path, csv_path, xlsx_path) =
3798        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3799            let scan_root = json_dir.parent()?;
3800            let html = find_html_report_in_dir(&scan_root.join("html"))
3801                .or_else(|| find_html_report_in_dir(scan_root));
3802            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3803            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3804            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3805            (html, pdf, csv, xlsx)
3806        } else {
3807            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3808                rd.flatten()
3809                    .map(|e| e.path())
3810                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3811            });
3812            (html, None, None, None)
3813        };
3814    let run = read_json(&json_path).ok()?;
3815    let project_label = run.input_roots.first().map_or_else(
3816        || "Unknown Project".to_string(),
3817        |r| sanitize_project_label(r),
3818    );
3819    Some(RegistryEntry {
3820        run_id: run.tool.run_id.clone(),
3821        timestamp_utc: run.tool.timestamp_utc,
3822        project_label,
3823        input_roots: run.input_roots.clone(),
3824        json_path: Some(json_path),
3825        html_path,
3826        pdf_path,
3827        csv_path,
3828        xlsx_path,
3829        summary: ScanSummarySnapshot::from(&run.summary_totals),
3830        git_branch: run.git_branch.clone(),
3831        git_commit: run.git_commit_short.clone(),
3832        git_commit_long: run.git_commit_long.clone(),
3833        git_author: run.git_commit_author.clone(),
3834        git_tags: run.git_tags.clone(),
3835        git_nearest_tag: run.git_nearest_tag.clone(),
3836        git_commit_date: run.git_commit_date,
3837    })
3838}
3839
3840/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3841/// Returns the number of newly linked entries.
3842fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3843    let mut linked = 0usize;
3844    for json_path in collect_result_json_candidates(folder) {
3845        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3846            continue;
3847        };
3848        if is_dir_already_registered(reg, &parent) {
3849            continue;
3850        }
3851        let Some(entry) = build_registry_entry_from_json(json_path) else {
3852            continue;
3853        };
3854        reg.add_entry(entry);
3855        linked += 1;
3856    }
3857    linked
3858}
3859
3860/// Scan all watched directories (plus the default output root) into `reg`.
3861async fn auto_scan_watched_dirs(state: &AppState) {
3862    let dirs: Vec<PathBuf> = {
3863        let wd = state.watched_dirs.lock().await;
3864        wd.dirs.clone()
3865    };
3866    if dirs.is_empty() {
3867        return;
3868    }
3869    let mut reg = state.registry.lock().await;
3870    let mut total = 0usize;
3871    for dir in &dirs {
3872        if dir.is_dir() {
3873            total += scan_folder_into_registry(dir, &mut reg);
3874        }
3875    }
3876    if total > 0 {
3877        let _ = reg.save(&state.registry_path);
3878    }
3879}
3880
3881// ── Watched-dir route forms ───────────────────────────────────────────────────
3882
3883#[derive(Deserialize)]
3884struct WatchedDirForm {
3885    folder_path: String,
3886    #[serde(default = "default_redirect")]
3887    redirect_to: String,
3888}
3889
3890fn default_redirect() -> String {
3891    "/view-reports".to_string()
3892}
3893
3894#[derive(Deserialize)]
3895struct WatchedDirRefreshForm {
3896    #[serde(default = "default_redirect")]
3897    redirect_to: String,
3898}
3899
3900// ── Watched-dir helpers ───────────────────────────────────────────────────────
3901
3902/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3903fn safe_redirect(dest: &str) -> &str {
3904    if dest.starts_with('/') {
3905        dest
3906    } else {
3907        "/"
3908    }
3909}
3910
3911// ── Watched-dir handlers ──────────────────────────────────────────────────────
3912
3913async fn add_watched_dir_handler(
3914    State(state): State<AppState>,
3915    Form(form): Form<WatchedDirForm>,
3916) -> impl IntoResponse {
3917    if state.server_mode {
3918        return StatusCode::NOT_FOUND.into_response();
3919    }
3920    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3921        strip_unc_prefix(p)
3922    } else {
3923        let dest = format!(
3924            "{}?error=Folder+not+found+or+path+is+invalid.",
3925            safe_redirect(&form.redirect_to)
3926        );
3927        return axum::response::Redirect::to(&dest).into_response();
3928    };
3929    if !folder.is_dir() {
3930        let dest = format!(
3931            "{}?error=Selected+path+is+not+a+directory.",
3932            safe_redirect(&form.redirect_to)
3933        );
3934        return axum::response::Redirect::to(&dest).into_response();
3935    }
3936
3937    // Persist the watched directory.
3938    {
3939        let mut wd = state.watched_dirs.lock().await;
3940        wd.add(folder.clone());
3941        let _ = wd.save(&state.watched_dirs_path);
3942    }
3943
3944    // Immediately scan the folder and add any new reports.
3945    let linked = {
3946        let mut reg = state.registry.lock().await;
3947        let n = scan_folder_into_registry(&folder, &mut reg);
3948        if n > 0 {
3949            let _ = reg.save(&state.registry_path);
3950        }
3951        n
3952    };
3953
3954    let dest = if linked > 0 {
3955        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3956    } else {
3957        format!(
3958            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3959            safe_redirect(&form.redirect_to)
3960        )
3961    };
3962    axum::response::Redirect::to(&dest).into_response()
3963}
3964
3965async fn remove_watched_dir_handler(
3966    State(state): State<AppState>,
3967    Form(form): Form<WatchedDirForm>,
3968) -> impl IntoResponse {
3969    if state.server_mode {
3970        return StatusCode::NOT_FOUND.into_response();
3971    }
3972    let folder = PathBuf::from(&form.folder_path);
3973    {
3974        let mut wd = state.watched_dirs.lock().await;
3975        wd.remove(&folder);
3976        let _ = wd.save(&state.watched_dirs_path);
3977    }
3978    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3979}
3980
3981async fn refresh_watched_dirs_handler(
3982    State(state): State<AppState>,
3983    Form(form): Form<WatchedDirRefreshForm>,
3984) -> impl IntoResponse {
3985    if state.server_mode {
3986        return StatusCode::NOT_FOUND.into_response();
3987    }
3988    let dirs: Vec<PathBuf> = {
3989        let wd = state.watched_dirs.lock().await;
3990        wd.dirs.clone()
3991    };
3992    let mut total = 0usize;
3993    {
3994        let mut reg = state.registry.lock().await;
3995        reg.prune_stale();
3996        for dir in &dirs {
3997            if dir.is_dir() {
3998                total += scan_folder_into_registry(dir, &mut reg);
3999            }
4000        }
4001        let _ = reg.save(&state.registry_path);
4002    }
4003    let dest = if total > 0 {
4004        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4005    } else {
4006        safe_redirect(&form.redirect_to).to_owned()
4007    };
4008    axum::response::Redirect::to(&dest).into_response()
4009}
4010
4011#[derive(Debug, Deserialize)]
4012struct OpenPathQuery {
4013    path: Option<String>,
4014}
4015
4016fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4017    let mut ancestor = std::path::Path::new(raw);
4018    loop {
4019        match ancestor.parent() {
4020            Some(p) => {
4021                ancestor = p;
4022                if ancestor.is_dir() {
4023                    break;
4024                }
4025            }
4026            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4027        }
4028    }
4029    Ok(ancestor.to_path_buf())
4030}
4031
4032async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4033    match tokio::fs::canonicalize(raw).await {
4034        Ok(canonical) if canonical.is_file() => canonical
4035            .parent()
4036            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4037                Ok(p.to_path_buf())
4038            }),
4039        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4040        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4041        Err(_) => find_existing_ancestor(raw),
4042    }
4043}
4044
4045async fn open_path_handler(
4046    State(state): State<AppState>,
4047    Query(query): Query<OpenPathQuery>,
4048) -> impl IntoResponse {
4049    if state.server_mode {
4050        return Json(serde_json::json!({
4051            "server_mode_disabled": true,
4052            "message": "Opening a path in the file manager is only available in local desktop mode."
4053        }))
4054        .into_response();
4055    }
4056    // Skip the OS file-manager call in headless / CI environments.
4057    if std::env::var("SLOC_HEADLESS").is_ok() {
4058        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4059    }
4060    let raw = match query.path.as_deref() {
4061        Some(p) if !p.is_empty() => p,
4062        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4063    };
4064
4065    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4066    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4067    // so the file explorer still opens somewhere useful.
4068    let target = match resolve_open_target(raw).await {
4069        Ok(p) => p,
4070        Err((code, msg)) => return (code, msg).into_response(),
4071    };
4072
4073    #[cfg(target_os = "windows")]
4074    win_dialog_focus::open_folder_foreground(target);
4075    #[cfg(target_os = "macos")]
4076    let _ = std::process::Command::new("open")
4077        .arg(&target)
4078        .stdout(Stdio::null())
4079        .stderr(Stdio::null())
4080        .spawn();
4081    #[cfg(target_os = "linux")]
4082    {
4083        let folder_name = target
4084            .file_name()
4085            .and_then(|n| n.to_str())
4086            .map(str::to_owned);
4087        let _ = std::process::Command::new("xdg-open")
4088            .arg(&target)
4089            .stdout(Stdio::null())
4090            .stderr(Stdio::null())
4091            .spawn();
4092        // Best-effort: raise the file manager window once it appears.
4093        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4094        // installed; failures are silently discarded.
4095        if let Some(name) = folder_name {
4096            std::thread::spawn(move || {
4097                std::thread::sleep(std::time::Duration::from_millis(800));
4098                let _ = std::process::Command::new("wmctrl")
4099                    .args(["-a", &name])
4100                    .stdout(Stdio::null())
4101                    .stderr(Stdio::null())
4102                    .spawn();
4103            });
4104        }
4105    }
4106
4107    Json(serde_json::json!({"ok": true})).into_response()
4108}
4109
4110async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4111    let (content_type, bytes): (&'static str, &'static [u8]) =
4112        match (folder.as_str(), file.as_str()) {
4113            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4114            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4115            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4116            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4117            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4118            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4119            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4120            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4121            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4122            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4123            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4124            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4125            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4126            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4127            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4128            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4129            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4130            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4131            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4132            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4133            _ => return StatusCode::NOT_FOUND.into_response(),
4134        };
4135    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4136}
4137
4138/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4139/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4140/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4141/// complexity low; the fail-closed semantics are unchanged.
4142fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4143    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4144    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4145    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4146    // sample/upload locations are permitted; everything else is rejected.
4147    let Ok(canonical) = fs::canonicalize(resolved) else {
4148        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4149            return Err(Html(
4150                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4151            ));
4152        }
4153        return Ok(());
4154    };
4155    // Upload temp dirs and built-in sample/fixture paths are always safe.
4156    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4157        return Ok(());
4158    }
4159    let config = &state.base_config;
4160    if config.discovery.allowed_scan_roots.is_empty() {
4161        return Err(Html(
4162            r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4163        ));
4164    }
4165    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4166        fs::canonicalize(root)
4167            .ok()
4168            .is_some_and(|r| canonical.starts_with(&r))
4169    });
4170    if !allowed {
4171        return Err(Html(
4172            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4173        ));
4174    }
4175    Ok(())
4176}
4177
4178async fn preview_handler(
4179    State(state): State<AppState>,
4180    Query(query): Query<PreviewQuery>,
4181) -> impl IntoResponse {
4182    let raw_path = query
4183        .path
4184        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
4185    let resolved = resolve_input_path(&raw_path);
4186
4187    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4188    // binary whose working directory is not the project root), return a clear message
4189    // instead of an opaque OS error from build_preview_html.
4190    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4191        return Html(
4192            r#"<div class="preview-error">Sample directory not available on this server.
4193            Enter a path to a project directory or upload files using Browse.</div>"#
4194                .to_string(),
4195        );
4196    }
4197
4198    if state.server_mode {
4199        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4200            return resp;
4201        }
4202    }
4203
4204    let include_patterns = split_patterns(query.include_globs.as_deref());
4205    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4206
4207    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4208        Ok(html) => Html(html),
4209        Err(err) => Html(format!(
4210            r#"<div class="preview-error">Preview failed: {}</div>"#,
4211            escape_html(&err.to_string())
4212        )),
4213    }
4214}
4215
4216#[derive(Debug, Deserialize, Default)]
4217struct SuggestCoverageQuery {
4218    path: Option<String>,
4219}
4220
4221#[derive(Serialize)]
4222struct SuggestCoverageResponse {
4223    found: Option<String>,
4224    tool: Option<&'static str>,
4225    hint: Option<&'static str>,
4226}
4227
4228async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4229    const CANDIDATES: &[&str] = &[
4230        // LCOV — cargo-llvm-cov, gcov, lcov
4231        "coverage/lcov.info",
4232        "lcov.info",
4233        "target/llvm-cov/lcov.info",
4234        "target/coverage/lcov.info",
4235        "target/debug/coverage/lcov.info",
4236        "coverage/coverage.lcov",
4237        "build/coverage/lcov.info",
4238        "reports/lcov.info",
4239        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4240        "coverage.xml",
4241        "coverage/coverage.xml",
4242        "target/site/cobertura/coverage.xml",
4243        "build/reports/coverage/coverage.xml",
4244        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4245        "target/site/jacoco/jacoco.xml",
4246        "build/reports/jacoco/test/jacocoTestReport.xml",
4247        "build/reports/jacoco/jacocoTestReport.xml",
4248        "build/jacoco/jacoco.xml",
4249        // coverage.py native JSON — `coverage json`
4250        "coverage.json",
4251        "coverage/coverage.json",
4252    ];
4253    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4254    let found = CANDIDATES
4255        .iter()
4256        .map(|rel| root.join(rel))
4257        .find(|p| p.is_file())
4258        .map(|p| display_path(&p));
4259
4260    let (tool, hint) = detect_coverage_tool(&root);
4261    Json(SuggestCoverageResponse { found, tool, hint })
4262}
4263
4264/// Inspect the project root for known build/package files and return the most likely coverage
4265/// tool name and the shell command needed to generate a coverage file.
4266fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4267    if root.join("Cargo.toml").is_file() {
4268        return (
4269            Some("cargo-llvm-cov"),
4270            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4271        );
4272    }
4273    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4274        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4275    }
4276    if root.join("pom.xml").is_file() {
4277        return (Some("jacoco"), Some("mvn test jacoco:report"));
4278    }
4279    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4280        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4281    }
4282    (None, None)
4283}
4284
4285/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4286#[allow(clippy::result_large_err)]
4287fn validate_server_scan_path(
4288    config: &sloc_config::AppConfig,
4289    resolved_path: &Path,
4290    csp_nonce: &str,
4291) -> Result<(), Response> {
4292    if config.discovery.allowed_scan_roots.is_empty() {
4293        let template = ErrorTemplate {
4294            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4295                      Set allowed_scan_roots in the server config to permit scanning."
4296                .to_string(),
4297            last_report_url: None,
4298            last_report_label: None,
4299            run_id: None,
4300            error_code: Some(403),
4301            csp_nonce: csp_nonce.to_owned(),
4302            version: env!("CARGO_PKG_VERSION"),
4303        };
4304        return Err((
4305            StatusCode::FORBIDDEN,
4306            Html(
4307                template
4308                    .render()
4309                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4310            ),
4311        )
4312            .into_response());
4313    }
4314    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4315    // location) we must NOT fall back to the raw, un-normalised path — a textual
4316    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4317    // allowlist. A non-resolvable scan target is rejected outright.
4318    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4319        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4320            "Scan path does not resolve to a real location");
4321        let template = ErrorTemplate {
4322            message: "The requested path could not be resolved to a real directory.".to_string(),
4323            last_report_url: None,
4324            last_report_label: None,
4325            run_id: None,
4326            error_code: Some(403),
4327            csp_nonce: csp_nonce.to_owned(),
4328            version: env!("CARGO_PKG_VERSION"),
4329        };
4330        return Err((
4331            StatusCode::FORBIDDEN,
4332            Html(
4333                template
4334                    .render()
4335                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4336            ),
4337        )
4338            .into_response());
4339    };
4340    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4341        fs::canonicalize(root)
4342            .ok()
4343            .is_some_and(|r| canonical.starts_with(&r))
4344    });
4345    if !allowed {
4346        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4347            "Scan path not in allowed_scan_roots");
4348        let template = ErrorTemplate {
4349            message: "The requested path is not within an allowed scan directory.".to_string(),
4350            last_report_url: None,
4351            last_report_label: None,
4352            run_id: None,
4353            error_code: Some(403),
4354            csp_nonce: csp_nonce.to_owned(),
4355            version: env!("CARGO_PKG_VERSION"),
4356        };
4357        return Err((
4358            StatusCode::FORBIDDEN,
4359            Html(
4360                template
4361                    .render()
4362                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4363            ),
4364        )
4365            .into_response());
4366    }
4367    Ok(())
4368}
4369
4370/// Exclude the output directory from scanning so artifacts don't pollute counts.
4371fn apply_output_dir_exclusions(
4372    config: &mut sloc_config::AppConfig,
4373    project_path: &str,
4374    raw_output_dir: &str,
4375) {
4376    let project_root = resolve_input_path(project_path);
4377    let raw_out = raw_output_dir.trim();
4378    let resolved_out = if raw_out.is_empty() {
4379        project_root.join("sloc")
4380    } else if Path::new(raw_out).is_absolute() {
4381        PathBuf::from(raw_out)
4382    } else {
4383        workspace_root().join(raw_out)
4384    };
4385    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4386        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4387            let dir = first.to_string();
4388            if !config.discovery.excluded_directories.contains(&dir) {
4389                config.discovery.excluded_directories.push(dir);
4390            }
4391        }
4392    }
4393    if !config
4394        .discovery
4395        .excluded_directories
4396        .iter()
4397        .any(|d| d == "sloc")
4398    {
4399        config
4400            .discovery
4401            .excluded_directories
4402            .push("sloc".to_string());
4403    }
4404}
4405
4406/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4407const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4408    ScanSummarySnapshot {
4409        files_analyzed: run.summary_totals.files_analyzed,
4410        files_skipped: run.summary_totals.files_skipped,
4411        total_physical_lines: run.summary_totals.total_physical_lines,
4412        code_lines: run.summary_totals.code_lines,
4413        comment_lines: run.summary_totals.comment_lines,
4414        blank_lines: run.summary_totals.blank_lines,
4415        functions: run.summary_totals.functions,
4416        classes: run.summary_totals.classes,
4417        variables: run.summary_totals.variables,
4418        imports: run.summary_totals.imports,
4419        test_count: run.summary_totals.test_count,
4420        coverage_lines_found: run.summary_totals.coverage_lines_found,
4421        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4422        coverage_functions_found: run.summary_totals.coverage_functions_found,
4423        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4424        coverage_branches_found: run.summary_totals.coverage_branches_found,
4425        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4426    }
4427}
4428
4429/// Build the `RegistryEntry` for the just-completed scan run.
4430pub(crate) fn build_run_registry_entry(
4431    run: &AnalysisRun,
4432    run_id: &str,
4433    project_label: &str,
4434    artifacts: &RunArtifacts,
4435) -> RegistryEntry {
4436    RegistryEntry {
4437        run_id: run_id.to_owned(),
4438        timestamp_utc: run.tool.timestamp_utc,
4439        project_label: project_label.to_owned(),
4440        input_roots: run.input_roots.clone(),
4441        json_path: artifacts.json_path.clone(),
4442        html_path: artifacts.html_path.clone(),
4443        pdf_path: artifacts.pdf_path.clone(),
4444        csv_path: artifacts.csv_path.clone(),
4445        xlsx_path: artifacts.xlsx_path.clone(),
4446        summary: summary_snapshot_from_run(run),
4447        git_branch: run.git_branch.clone(),
4448        git_commit: run.git_commit_short.clone(),
4449        git_commit_long: run.git_commit_long.clone(),
4450        git_author: run.git_commit_author.clone(),
4451        git_tags: run.git_tags.clone(),
4452        git_nearest_tag: run.git_nearest_tag.clone(),
4453        git_commit_date: run.git_commit_date.clone(),
4454    }
4455}
4456
4457/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4458fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4459    if let Some(policy) = form.mixed_line_policy {
4460        config.analysis.mixed_line_policy = policy;
4461    }
4462    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4463    config.analysis.generated_file_detection =
4464        form.generated_file_detection.as_deref() != Some("disabled");
4465    config.analysis.minified_file_detection =
4466        form.minified_file_detection.as_deref() != Some("disabled");
4467    config.analysis.vendor_directory_detection =
4468        form.vendor_directory_detection.as_deref() != Some("disabled");
4469    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4470    if let Some(binary_behavior) = form.binary_file_behavior {
4471        config.analysis.binary_file_behavior = binary_behavior;
4472    }
4473    apply_report_opts(config, form);
4474    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4475    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4476    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4477    if let Some(policy) = form.continuation_line_policy {
4478        config.analysis.continuation_line_policy = policy;
4479    }
4480    if let Some(policy) = form.blank_in_block_comment_policy {
4481        config.analysis.blank_in_block_comment_policy = policy;
4482    }
4483    config.analysis.count_compiler_directives =
4484        form.count_compiler_directives.as_deref() != Some("disabled");
4485    apply_style_threshold(config, form);
4486    apply_coverage_path(config, form);
4487}
4488
4489fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4490    if let Some(report_title) = form.report_title.as_deref() {
4491        let trimmed = report_title.trim();
4492        if !trimmed.is_empty() {
4493            config.reporting.report_title = trimmed.to_string();
4494        }
4495    }
4496    if let Some(hf) = form.report_header_footer.as_deref() {
4497        let trimmed = hf.trim();
4498        config.reporting.report_header_footer = if trimmed.is_empty() {
4499            None
4500        } else {
4501            Some(trimmed.to_string())
4502        };
4503    }
4504}
4505
4506fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4507    apply_style_col_threshold(config, form);
4508    apply_style_analysis_enabled(config, form);
4509    apply_style_score_threshold(config, form);
4510    apply_style_lang_scope(config, form);
4511    apply_activity_window(config, form);
4512}
4513
4514fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4515    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4516        if let Ok(t) = threshold_str.parse::<u16>() {
4517            if t == 80 || t == 100 || t == 120 {
4518                config.analysis.style_col_threshold = t;
4519            }
4520        }
4521    }
4522}
4523
4524fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4525    if let Some(v) = form.style_analysis_enabled.as_deref() {
4526        config.analysis.style_analysis_enabled = v != "disabled";
4527    }
4528}
4529
4530fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4531    if let Some(v) = form.style_score_threshold.as_deref() {
4532        if let Ok(t) = v.parse::<u8>() {
4533            config.analysis.style_score_threshold = t.min(100);
4534        }
4535    }
4536}
4537
4538fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4539    if let Some(v) = form.style_lang_scope.as_deref() {
4540        let scope = v.trim();
4541        if scope == "c_family" || scope == "all" {
4542            config.analysis.style_lang_scope = scope.to_string();
4543        }
4544    }
4545}
4546
4547fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4548    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
4549    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
4550    if let Some(w) = form.activity_window.as_deref() {
4551        let w = w.trim();
4552        if !w.is_empty() {
4553            if let Ok(days) = w.parse::<u32>() {
4554                config.analysis.activity_window_days = Some(days);
4555            }
4556        }
4557    }
4558}
4559
4560fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4561    if let Some(cov) = &form.coverage_file {
4562        let trimmed = cov.trim();
4563        if !trimmed.is_empty() {
4564            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4565        }
4566    }
4567}
4568
4569/// Fire-and-forget: generate the PDF in a background task if one is pending.
4570/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4571/// an error instead of spinning indefinitely.
4572fn spawn_pdf_background(
4573    pending_pdf: PendingPdf,
4574    run_id: String,
4575    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4576) {
4577    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4578        tokio::spawn(async move {
4579            let result = tokio::task::spawn_blocking(move || {
4580                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4581                if cleanup_src {
4582                    let _ = fs::remove_file(&pdf_src);
4583                }
4584                r
4585            })
4586            .await;
4587            let failed = match result {
4588                Ok(Ok(())) => false,
4589                Ok(Err(err)) => {
4590                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4591                    true
4592                }
4593                Err(err) => {
4594                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4595                    true
4596                }
4597            };
4598            if failed {
4599                let mut map = artifacts.lock().await;
4600                if let Some(entry) = map.get_mut(&run_id) {
4601                    entry.pdf_path = None;
4602                }
4603            }
4604        });
4605    }
4606}
4607
4608/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4609/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4610/// result page can show an error on the next visit instead of spinning indefinitely.
4611fn spawn_native_pdf_background(
4612    json_path: PathBuf,
4613    pdf_dest: PathBuf,
4614    run_id: String,
4615    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4616) {
4617    tokio::spawn(async move {
4618        let result = tokio::task::spawn_blocking(move || {
4619            let run = sloc_core::read_json(&json_path)?;
4620            write_pdf_from_run(&run, &pdf_dest)
4621        })
4622        .await;
4623        let failed = match result {
4624            Ok(Ok(())) => false,
4625            Ok(Err(err)) => {
4626                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4627                true
4628            }
4629            Err(err) => {
4630                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4631                true
4632            }
4633        };
4634        if failed {
4635            let mut map = artifacts.lock().await;
4636            if let Some(entry) = map.get_mut(&run_id) {
4637                entry.pdf_path = None;
4638            }
4639        }
4640    });
4641}
4642
4643/// Sum the code lines added in this comparison (new + grown files).
4644fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4645    cmp.file_deltas
4646        .iter()
4647        .map(|f| match f.status {
4648            FileChangeStatus::Added => f.current_code,
4649            FileChangeStatus::Modified => f.code_delta.max(0),
4650            _ => 0,
4651        })
4652        .sum()
4653}
4654
4655/// Sum the code lines removed in this comparison (deleted + shrunk files).
4656fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4657    cmp.file_deltas
4658        .iter()
4659        .map(|f| match f.status {
4660            FileChangeStatus::Removed => f.baseline_code,
4661            FileChangeStatus::Modified => (-f.code_delta).max(0),
4662            _ => 0,
4663        })
4664        .sum()
4665}
4666
4667/// Sum the code lines present in both scans without any change (Unchanged files).
4668fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4669    cmp.file_deltas
4670        .iter()
4671        .filter(|f| f.status == FileChangeStatus::Unchanged)
4672        .map(|f| f.current_code)
4673        .sum()
4674}
4675
4676/// Sum the code lines residing in files that were modified between the two scans.
4677fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4678    cmp.file_deltas
4679        .iter()
4680        .filter(|f| f.status == FileChangeStatus::Modified)
4681        .map(|f| f.current_code)
4682        .sum()
4683}
4684
4685/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4686fn build_submodule_row(
4687    s: &sloc_core::SubmoduleSummary,
4688    run: &AnalysisRun,
4689    run_id: &str,
4690    run_dir: &Path,
4691) -> SubmoduleRow {
4692    let safe = sanitize_project_label(&s.name);
4693    let artifact_key = format!("sub_{safe}");
4694    let pdf_artifact_key = format!("sub_{safe}_pdf");
4695    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4696        let parent_path = run
4697            .input_roots
4698            .first()
4699            .map_or("", std::string::String::as_str);
4700        let sub_run = build_sub_run(run, s, parent_path);
4701        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4702        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4703            .ok()
4704            .and_then(|sub_html| {
4705                let sub_dir = run_dir.join("submodules");
4706                let _ = fs::create_dir_all(&sub_dir);
4707                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4708                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4709                    // Pre-generate the sub-report PDF using the programmatic renderer
4710                    // so "View PDF" never needs to spawn Chrome for submodules.
4711                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4712                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4713                    Some(format!("/runs/{artifact_key}/{run_id}"))
4714                } else {
4715                    None
4716                }
4717            })
4718    } else {
4719        None
4720    };
4721    SubmoduleRow {
4722        name: s.name.clone(),
4723        relative_path: s.relative_path.clone(),
4724        files_analyzed: s.files_analyzed,
4725        code_lines: s.code_lines,
4726        comment_lines: s.comment_lines,
4727        blank_lines: s.blank_lines,
4728        total_physical_lines: s.total_physical_lines,
4729        html_url,
4730    }
4731}
4732
4733// Immediately returns a wait page and runs the analysis in a background tokio task.
4734// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4735#[allow(clippy::similar_names)]
4736#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4737#[allow(clippy::too_many_lines)]
4738async fn analyze_handler(
4739    State(state): State<AppState>,
4740    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4741    Form(form): Form<AnalyzeForm>,
4742) -> impl IntoResponse {
4743    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4744        let template = ErrorTemplate {
4745            message: format!(
4746                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4747             Please wait a moment and try again."
4748            ),
4749            last_report_url: None,
4750            last_report_label: None,
4751            run_id: None,
4752            error_code: Some(503),
4753            csp_nonce: csp_nonce.clone(),
4754            version: env!("CARGO_PKG_VERSION"),
4755        };
4756        return (
4757            StatusCode::SERVICE_UNAVAILABLE,
4758            Html(
4759                template
4760                    .render()
4761                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4762            ),
4763        )
4764            .into_response();
4765    };
4766
4767    let mut config = state.base_config.clone();
4768
4769    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4770    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4771    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4772
4773    if !is_git_mode {
4774        let resolved_path = resolve_input_path(&form.path);
4775        if state.server_mode
4776            && !is_upload_tmp_path(&resolved_path)
4777            && !is_sample_path(&resolved_path)
4778        {
4779            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4780                return resp;
4781            }
4782        }
4783        config.discovery.root_paths = vec![resolved_path];
4784    }
4785
4786    apply_form_to_config(&mut config, &form);
4787    apply_output_dir_exclusions(
4788        &mut config,
4789        &form.path,
4790        form.output_dir.as_deref().unwrap_or(""),
4791    );
4792
4793    // Generate a wait_id now (before spawning) so the client can poll for status.
4794    let wait_id = uuid::Uuid::new_v4().to_string();
4795    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4796
4797    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4798    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4799    let task_cancel = Arc::clone(&cancel_token);
4800
4801    // Phase tracker: updated by run_analysis_task at key checkpoints.
4802    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4803    let task_phase = Arc::clone(&phase);
4804
4805    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4806    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4807    let task_files_done = Arc::clone(&files_done);
4808    let task_files_total = Arc::clone(&files_total);
4809
4810    // Register Running state before building the task struct so the semaphore permit
4811    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4812    {
4813        let mut runs = state.async_runs.lock().await;
4814        runs.insert(
4815            wait_id.clone(),
4816            AsyncRunState::Running {
4817                started_at: std::time::Instant::now(),
4818                cancel_token,
4819                phase,
4820                files_done,
4821                files_total,
4822            },
4823        );
4824    }
4825
4826    let task = AnalysisTask {
4827        sem_permit,
4828        state: state.clone(),
4829        wait_id: wait_id.clone(),
4830        config,
4831        cancel: task_cancel,
4832        phase: task_phase,
4833        files_done: task_files_done,
4834        files_total: task_files_total,
4835        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4836        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4837        project_path: form.path.clone(),
4838        // In server mode the client-supplied output_dir is ignored — artifacts are
4839        // always written under the server's configured output root so remote users
4840        // cannot direct writes to arbitrary filesystem paths.
4841        output_dir: if state.server_mode {
4842            None
4843        } else {
4844            form.output_dir.clone()
4845        },
4846        clones_dir: state.git_clones_dir.clone(),
4847        cocomo_mode: form
4848            .cocomo_mode
4849            .clone()
4850            .unwrap_or_else(|| "organic".to_string()),
4851        complexity_alert: form
4852            .complexity_alert
4853            .as_deref()
4854            .and_then(|s| s.parse::<u32>().ok())
4855            .unwrap_or(0),
4856        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4857    };
4858
4859    tokio::spawn(run_analysis_task(task));
4860
4861    let template = ScanWaitTemplate {
4862        version: env!("CARGO_PKG_VERSION"),
4863        wait_id_json,
4864        project_path: form.path.clone(),
4865        csp_nonce,
4866    };
4867    let html = template
4868        .render()
4869        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4870    let mut response = Html(html).into_response();
4871    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4872        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4873            response.headers_mut().insert(name, val);
4874        }
4875    }
4876    response
4877}
4878
4879struct AnalysisTask {
4880    sem_permit: tokio::sync::OwnedSemaphorePermit,
4881    state: AppState,
4882    wait_id: String,
4883    config: AppConfig,
4884    cancel: Arc<std::sync::atomic::AtomicBool>,
4885    phase: Arc<std::sync::Mutex<String>>,
4886    files_done: Arc<std::sync::atomic::AtomicUsize>,
4887    files_total: Arc<std::sync::atomic::AtomicUsize>,
4888    git_repo: Option<String>,
4889    git_ref: Option<String>,
4890    project_path: String,
4891    output_dir: Option<String>,
4892    clones_dir: PathBuf,
4893    cocomo_mode: String,
4894    complexity_alert: u32,
4895    exclude_duplicates: bool,
4896}
4897
4898#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4899async fn run_analysis_task(task: AnalysisTask) {
4900    let _permit = task.sem_permit;
4901
4902    let cancel_sb = Arc::clone(&task.cancel);
4903    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4904    let clones_dir_sb = task.clones_dir;
4905    // Save the upload staging path before config is moved into spawn_blocking.
4906    let upload_staging_root = task
4907        .config
4908        .discovery
4909        .root_paths
4910        .first()
4911        .filter(|p| is_upload_tmp_path(p))
4912        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4913        .map(PathBuf::from);
4914    let config_sb = task.config;
4915    let progress_sb = sloc_core::ProgressCounters {
4916        files_done: Arc::clone(&task.files_done),
4917        files_total: Arc::clone(&task.files_total),
4918    };
4919    if let Ok(mut p) = task.phase.lock() {
4920        *p = "Scanning files".to_string();
4921    }
4922    let analysis_result = tokio::task::spawn_blocking(move || {
4923        run_analysis_blocking(
4924            config_sb,
4925            git_repo_sb,
4926            git_ref_sb,
4927            clones_dir_sb,
4928            cancel_sb,
4929            Some(progress_sb),
4930        )
4931    })
4932    .await
4933    .map_err(|err| anyhow::anyhow!(err.to_string()))
4934    .and_then(|result| result);
4935
4936    if let Ok(mut p) = task.phase.lock() {
4937        *p = "Writing reports".to_string();
4938    }
4939
4940    // If cancelled while running, discard results and mark as cancelled.
4941    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4942        let mut runs = task.state.async_runs.lock().await;
4943        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4944        if matches!(
4945            runs.get(&task.wait_id),
4946            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4947        ) {
4948            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4949        }
4950        drop(runs);
4951        return;
4952    }
4953
4954    let run = match analysis_result {
4955        Ok(v) => v,
4956        Err(err) => {
4957            // Distinguish user-cancelled from real failure.
4958            if err.to_string().contains("analysis cancelled") {
4959                let mut runs = task.state.async_runs.lock().await;
4960                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4961                drop(runs);
4962                return;
4963            }
4964            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4965            let mut runs = task.state.async_runs.lock().await;
4966            runs.insert(
4967                task.wait_id.clone(),
4968                AsyncRunState::Failed {
4969                    message: "Analysis failed. Check that the path exists and is readable."
4970                        .to_string(),
4971                },
4972            );
4973            drop(runs);
4974            return;
4975        }
4976    };
4977
4978    let run_id = run.tool.run_id.clone();
4979    tracing::info!(event = "scan_complete", run_id = %run_id,
4980        path = %task.project_path, files = run.summary_totals.files_analyzed,
4981        "Analysis finished");
4982
4983    let prev_entry: Option<RegistryEntry> = {
4984        let reg = task.state.registry.lock().await;
4985        reg.entries_for_roots(&run.input_roots)
4986            .into_iter()
4987            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4988            .cloned()
4989    };
4990
4991    let scan_delta = prev_entry.as_ref().and_then(|prev| {
4992        prev.json_path
4993            .as_ref()
4994            .and_then(|p| read_json(p).ok())
4995            .map(|prev_run| compute_delta(&prev_run, &run))
4996    });
4997    let prev_scan_count: usize = {
4998        let reg = task.state.registry.lock().await;
4999        reg.entries_for_roots(&run.input_roots)
5000            .iter()
5001            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5002            .count()
5003    };
5004
5005    // Build the HTML report now that delta is available, so the artifact
5006    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5007    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5008        .as_ref()
5009        .zip(prev_entry.as_ref())
5010        .map(|(cmp, prev)| ReportDeltaContext {
5011            delta_code_added: sum_added_code_lines(cmp),
5012            delta_code_removed: sum_removed_code_lines(cmp),
5013            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5014            delta_files_added: cmp.files_added,
5015            delta_files_removed: cmp.files_removed,
5016            delta_files_modified: cmp.files_modified,
5017            delta_files_unchanged: cmp.files_unchanged,
5018            prev_code_lines: prev.summary.code_lines,
5019            prev_scan_count: prev_scan_count + 1,
5020            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5021            prev_run_id: Some(prev.run_id.clone()),
5022            current_run_id: Some(run_id.clone()),
5023        });
5024    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5025        Ok(h) => h,
5026        Err(err) => {
5027            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5028            let mut runs = task.state.async_runs.lock().await;
5029            runs.insert(
5030                task.wait_id.clone(),
5031                AsyncRunState::Failed {
5032                    message: "Failed to render HTML report.".to_string(),
5033                },
5034            );
5035            drop(runs);
5036            return;
5037        }
5038    };
5039
5040    let output_root = resolve_output_root(task.output_dir.as_deref());
5041    let project_label = derive_project_label(
5042        task.git_repo.as_deref(),
5043        task.git_ref.as_deref(),
5044        &task.project_path,
5045    );
5046    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5047    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5048
5049    let result_context = RunResultContext {
5050        prev_entry: prev_entry.clone(),
5051        prev_scan_count,
5052        project_path: task.project_path.clone(),
5053        cocomo_mode: task.cocomo_mode.clone(),
5054        complexity_alert: task.complexity_alert,
5055        exclude_duplicates: task.exclude_duplicates,
5056    };
5057
5058    let artifact_result = persist_run_artifacts(
5059        &run,
5060        &report_html,
5061        &run_dir,
5062        &run.effective_configuration.reporting.report_title,
5063        &file_stem,
5064        result_context,
5065    );
5066
5067    let (artifacts, pending_pdf) = match artifact_result {
5068        Ok(v) => v,
5069        Err(err) => {
5070            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5071            let mut runs = task.state.async_runs.lock().await;
5072            runs.insert(
5073                task.wait_id.clone(),
5074                AsyncRunState::Failed {
5075                    message: "Failed to save report artifacts. Check available disk space."
5076                        .to_string(),
5077                },
5078            );
5079            drop(runs);
5080            return;
5081        }
5082    };
5083
5084    {
5085        let mut map = task.state.artifacts.lock().await;
5086        map.insert(run_id.clone(), artifacts.clone());
5087    }
5088
5089    {
5090        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5091        let mut reg = task.state.registry.lock().await;
5092        reg.add_entry(entry);
5093        let _ = reg.save(&task.state.registry_path);
5094    }
5095
5096    if let Some(ref cfg_path) = artifacts.scan_config_path {
5097        save_scan_config_json(
5098            cfg_path,
5099            &run,
5100            &task.project_path,
5101            task.output_dir.as_deref(),
5102            &task.cocomo_mode,
5103            task.complexity_alert,
5104            task.exclude_duplicates,
5105        );
5106    }
5107
5108    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5109
5110    prom_runs_total().inc();
5111
5112    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5113    let mut runs = task.state.async_runs.lock().await;
5114    runs.insert(
5115        task.wait_id.clone(),
5116        AsyncRunState::Complete {
5117            run_id: run_id.clone(),
5118        },
5119    );
5120    drop(runs);
5121
5122    // Remove the client-upload staging directory after a successful scan so
5123    // that uploaded project files don't accumulate in the OS temp directory.
5124    if let Some(staging) = upload_staging_root {
5125        let _ = tokio::fs::remove_dir_all(staging).await;
5126    }
5127
5128    let _ = scan_delta;
5129}
5130
5131fn save_scan_config_json(
5132    cfg_path: &std::path::Path,
5133    run: &sloc_core::AnalysisRun,
5134    project_path: &str,
5135    output_dir: Option<&str>,
5136    cocomo_mode: &str,
5137    complexity_alert: u32,
5138    exclude_duplicates: bool,
5139) {
5140    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5141        .ok()
5142        .and_then(|v| v.as_str().map(String::from))
5143        .unwrap_or_else(|| "code_only".to_string());
5144    let behavior_str =
5145        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5146            .ok()
5147            .and_then(|v| v.as_str().map(String::from))
5148            .unwrap_or_else(|| "skip".to_string());
5149    let continuation_policy_str = serde_json::to_value(
5150        run.effective_configuration
5151            .analysis
5152            .continuation_line_policy,
5153    )
5154    .ok()
5155    .and_then(|v| v.as_str().map(String::from))
5156    .unwrap_or_else(default_each_physical_line);
5157    let blank_policy_str = serde_json::to_value(
5158        run.effective_configuration
5159            .analysis
5160            .blank_in_block_comment_policy,
5161    )
5162    .ok()
5163    .and_then(|v| v.as_str().map(String::from))
5164    .unwrap_or_else(default_count_as_comment);
5165    let scan_cfg = ScanConfig {
5166        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5167        path: project_path.to_string(),
5168        include_globs: run
5169            .effective_configuration
5170            .discovery
5171            .include_globs
5172            .join("\n"),
5173        exclude_globs: run
5174            .effective_configuration
5175            .discovery
5176            .exclude_globs
5177            .join("\n"),
5178        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5179        mixed_line_policy: policy_str,
5180        python_docstrings_as_comments: run
5181            .effective_configuration
5182            .analysis
5183            .python_docstrings_as_comments,
5184        generated_file_detection: run
5185            .effective_configuration
5186            .analysis
5187            .generated_file_detection,
5188        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5189        vendor_directory_detection: run
5190            .effective_configuration
5191            .analysis
5192            .vendor_directory_detection,
5193        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5194        binary_file_behavior: behavior_str,
5195        output_dir: output_dir.unwrap_or("").to_string(),
5196        report_title: run.effective_configuration.reporting.report_title.clone(),
5197        continuation_line_policy: continuation_policy_str,
5198        blank_in_block_comment_policy: blank_policy_str,
5199        count_compiler_directives: run
5200            .effective_configuration
5201            .analysis
5202            .count_compiler_directives,
5203        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5204        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5205        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5206        style_lang_scope: run
5207            .effective_configuration
5208            .analysis
5209            .style_lang_scope
5210            .clone(),
5211        coverage_file: run
5212            .effective_configuration
5213            .analysis
5214            .coverage_file
5215            .as_ref()
5216            .map(|p| p.display().to_string())
5217            .unwrap_or_default(),
5218        cocomo_mode: cocomo_mode.to_string(),
5219        complexity_alert,
5220        exclude_duplicates,
5221        activity_window: run
5222            .effective_configuration
5223            .analysis
5224            .activity_window_days
5225            .unwrap_or(0),
5226    };
5227    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5228        let _ = std::fs::write(cfg_path, json);
5229    }
5230}
5231
5232#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5233fn run_analysis_blocking(
5234    mut config: AppConfig,
5235    git_repo: Option<String>,
5236    git_ref: Option<String>,
5237    clones_dir: PathBuf,
5238    cancel: Arc<std::sync::atomic::AtomicBool>,
5239    progress: Option<sloc_core::ProgressCounters>,
5240) -> Result<sloc_core::AnalysisRun> {
5241    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5242        let dest = git_clone_dest(&repo, &clones_dir);
5243        sloc_git::clone_or_fetch(&repo, &dest)?;
5244        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5245        sloc_git::create_worktree(&dest, &refname, &wt)?;
5246        config.discovery.root_paths = vec![wt.clone()];
5247        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5248        let _ = sloc_git::destroy_worktree(&dest, &wt);
5249        let mut run = run?;
5250        if run.git_branch.is_none() {
5251            run.git_branch = Some(refname);
5252        }
5253        return Ok(run);
5254    }
5255    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5256}
5257
5258fn derive_project_label(
5259    git_repo: Option<&str>,
5260    git_ref: Option<&str>,
5261    fallback_path: &str,
5262) -> String {
5263    match (
5264        git_repo.filter(|s| !s.is_empty()),
5265        git_ref.filter(|s| !s.is_empty()),
5266    ) {
5267        (Some(repo), Some(refname)) => {
5268            let repo_name = repo
5269                .trim_end_matches('/')
5270                .trim_end_matches(".git")
5271                .rsplit('/')
5272                .next()
5273                .unwrap_or("repo");
5274            sanitize_project_label(&format!("{repo_name}_{refname}"))
5275        }
5276        _ => sanitize_project_label(fallback_path),
5277    }
5278}
5279
5280fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5281    let commit = commit_short.unwrap_or("").trim();
5282    if commit.is_empty() {
5283        project_label.to_string()
5284    } else {
5285        format!("{project_label}_{commit}")
5286    }
5287}
5288
5289// ── Async scan status + result handlers ──────────────────────────────────────
5290
5291#[derive(Serialize)]
5292#[serde(tag = "state", rename_all = "snake_case")]
5293enum AsyncRunStatusResponse {
5294    Running {
5295        elapsed_secs: u64,
5296        phase: String,
5297        files_done: u64,
5298        files_total: u64,
5299    },
5300    Complete {
5301        run_id: String,
5302    },
5303    Failed {
5304        message: String,
5305    },
5306    Cancelled,
5307}
5308
5309async fn async_run_status_handler(
5310    State(state): State<AppState>,
5311    AxumPath(wait_id): AxumPath<String>,
5312) -> Response {
5313    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5314    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5315        return error::bad_request("invalid wait_id");
5316    }
5317    let run_state = {
5318        let runs = state.async_runs.lock().await;
5319        runs.get(&wait_id).cloned()
5320    };
5321    match run_state {
5322        None => error::not_found("run not found"),
5323        Some(AsyncRunState::Running {
5324            started_at,
5325            phase,
5326            files_done,
5327            files_total,
5328            ..
5329        }) => {
5330            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5331            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5332                let mut runs = state.async_runs.lock().await;
5333                runs.insert(
5334                    wait_id,
5335                    AsyncRunState::Failed {
5336                        message: "Analysis timed out after 2 hours.".to_string(),
5337                    },
5338                );
5339                drop(runs);
5340                return Json(AsyncRunStatusResponse::Failed {
5341                    message: "Analysis timed out after 2 hours.".to_string(),
5342                })
5343                .into_response();
5344            }
5345            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5346            Json(AsyncRunStatusResponse::Running {
5347                elapsed_secs: started_at.elapsed().as_secs(),
5348                phase: phase_str,
5349                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5350                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5351            })
5352            .into_response()
5353        }
5354        Some(AsyncRunState::Complete { run_id }) => {
5355            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5356        }
5357        Some(AsyncRunState::Failed { message }) => {
5358            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5359        }
5360        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5361    }
5362}
5363
5364async fn cancel_run_handler(
5365    State(state): State<AppState>,
5366    AxumPath(wait_id): AxumPath<String>,
5367) -> Response {
5368    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5369        return error::bad_request("invalid wait_id");
5370    }
5371    let mut runs = state.async_runs.lock().await;
5372    let resp = match runs.get(&wait_id) {
5373        Some(AsyncRunState::Running { cancel_token, .. }) => {
5374            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5375            runs.insert(wait_id, AsyncRunState::Cancelled);
5376            StatusCode::OK.into_response()
5377        }
5378        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5379        _ => error::not_found("run not found"),
5380    };
5381    drop(runs);
5382    resp
5383}
5384
5385async fn async_run_result_handler(
5386    State(state): State<AppState>,
5387    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5388    AxumPath(run_id): AxumPath<String>,
5389) -> Response {
5390    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5391        return StatusCode::BAD_REQUEST.into_response();
5392    }
5393
5394    let artifacts = {
5395        let map = state.artifacts.lock().await;
5396        map.get(&run_id).cloned()
5397    };
5398    let artifacts = if let Some(a) = artifacts {
5399        a
5400    } else {
5401        let reg = state.registry.lock().await;
5402        if let Some(entry) = reg.find_by_run_id(&run_id) {
5403            recover_artifacts_from_registry(entry)
5404        } else {
5405            let html = ErrorTemplate {
5406                message: format!(
5407                    "Report not found. Run ID {} is not in the scan history.",
5408                    &run_id[..run_id.len().min(8)]
5409                ),
5410                last_report_url: Some("/view-reports".to_string()),
5411                last_report_label: Some("View Reports".to_string()),
5412                run_id: Some(run_id.clone()),
5413                error_code: Some(404),
5414                csp_nonce: csp_nonce.clone(),
5415                version: env!("CARGO_PKG_VERSION"),
5416            }
5417            .render()
5418            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5419            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5420        }
5421    };
5422
5423    let json_path = if let Some(p) = &artifacts.json_path {
5424        p.clone()
5425    } else {
5426        let html = ErrorTemplate {
5427            message: "JSON result was not saved for this run.".to_string(),
5428            last_report_url: Some("/view-reports".to_string()),
5429            last_report_label: Some("View Reports".to_string()),
5430            run_id: Some(run_id.clone()),
5431            error_code: Some(404),
5432            csp_nonce: csp_nonce.clone(),
5433            version: env!("CARGO_PKG_VERSION"),
5434        }
5435        .render()
5436        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5437        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5438    };
5439
5440    let Ok(run) = read_json(&json_path) else {
5441        let folder_hint = output_folder_hint(&json_path);
5442        let redirect_url = format!("/runs/result/{run_id}");
5443        return missing_scan_relocate_response(
5444            &format!(
5445                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5446                 deleted. Browse to the folder containing your scan output to reconnect it.",
5447                json_path.display()
5448            ),
5449            &run_id,
5450            &folder_hint,
5451            &redirect_url,
5452            state.server_mode,
5453            &csp_nonce,
5454        );
5455    };
5456
5457    let confluence_configured = {
5458        let store = state.confluence.lock().await;
5459        store.is_configured()
5460    };
5461
5462    render_result_page(
5463        &run,
5464        &artifacts,
5465        &run_id,
5466        &csp_nonce,
5467        confluence_configured,
5468        state.server_mode,
5469    )
5470}
5471
5472/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5473fn json_escape(s: &str) -> String {
5474    s.replace('\\', "\\\\").replace('"', "\\\"")
5475}
5476
5477/// Per-language line/symbol totals summed across every language in a run.
5478struct LangTotals {
5479    physical_lines: u64,
5480    code_lines: u64,
5481    comment_lines: u64,
5482    blank_lines: u64,
5483    mixed_lines: u64,
5484    functions: u64,
5485    classes: u64,
5486    variables: u64,
5487    imports: u64,
5488}
5489
5490fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5491    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5492        run.totals_by_language.iter().map(f).sum()
5493    };
5494    LangTotals {
5495        physical_lines: s(|r| r.total_physical_lines),
5496        code_lines: s(|r| r.code_lines),
5497        comment_lines: s(|r| r.comment_lines),
5498        blank_lines: s(|r| r.blank_lines),
5499        mixed_lines: s(|r| r.mixed_lines_separate),
5500        functions: s(|r| r.functions),
5501        classes: s(|r| r.classes),
5502        variables: s(|r| r.variables),
5503        imports: s(|r| r.imports),
5504    }
5505}
5506
5507/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5508struct DeltaFields {
5509    prev_fa_str: String,
5510    prev_fs_str: String,
5511    prev_pl_str: String,
5512    prev_cl_str: String,
5513    prev_cml_str: String,
5514    prev_bl_str: String,
5515    delta_fa_str: String,
5516    delta_fa_class: String,
5517    delta_fs_str: String,
5518    delta_fs_class: String,
5519    delta_pl_str: String,
5520    delta_pl_class: String,
5521    delta_cl_str: String,
5522    delta_cl_class: String,
5523    delta_cml_str: String,
5524    delta_cml_class: String,
5525    delta_bl_str: String,
5526    delta_bl_class: String,
5527    delta_lines_added: Option<i64>,
5528    delta_lines_removed: Option<i64>,
5529    delta_lines_net_str: String,
5530    delta_lines_net_class: String,
5531}
5532
5533// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5534// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5535// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5536// from those field names and obscure the 1:1 mapping.
5537#[allow(
5538    clippy::similar_names,
5539    reason = "locals mirror template-bound struct fields"
5540)]
5541fn compute_delta_fields(
5542    prev_entry: Option<&RegistryEntry>,
5543    totals: &LangTotals,
5544    files_analyzed: u64,
5545    files_skipped: u64,
5546    scan_delta: Option<&sloc_core::ScanComparison>,
5547) -> DeltaFields {
5548    let prev_sum = prev_entry.map(|e| &e.summary);
5549    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5550
5551    let (delta_fa_str, delta_fa_class) =
5552        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5553    let (delta_fs_str, delta_fs_class) =
5554        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5555    let (delta_pl_str, delta_pl_class) = summary_delta(
5556        totals.physical_lines,
5557        prev_sum.map(|s| s.total_physical_lines),
5558    );
5559    let (delta_cl_str, delta_cl_class) =
5560        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5561    let (delta_cml_str, delta_cml_class) =
5562        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5563    let (delta_bl_str, delta_bl_class) =
5564        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5565
5566    let delta_lines_added = scan_delta.map(sum_added_code_lines);
5567    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5568    let (delta_lines_net_str, delta_lines_net_class) =
5569        match (delta_lines_added, delta_lines_removed) {
5570            (Some(a), Some(r)) => {
5571                let net = a - r;
5572                (fmt_delta(net), delta_class(net).to_string())
5573            }
5574            _ => ("\u{2014}".to_string(), "na".to_string()),
5575        };
5576
5577    DeltaFields {
5578        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5579        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5580        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5581        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5582        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5583        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5584        delta_fa_str,
5585        delta_fa_class: delta_fa_class.to_string(),
5586        delta_fs_str,
5587        delta_fs_class: delta_fs_class.to_string(),
5588        delta_pl_str,
5589        delta_pl_class: delta_pl_class.to_string(),
5590        delta_cl_str,
5591        delta_cl_class: delta_cl_class.to_string(),
5592        delta_cml_str,
5593        delta_cml_class: delta_cml_class.to_string(),
5594        delta_bl_str,
5595        delta_bl_class: delta_bl_class.to_string(),
5596        delta_lines_added,
5597        delta_lines_removed,
5598        delta_lines_net_str,
5599        delta_lines_net_class,
5600    }
5601}
5602
5603/// Count of unchanged code lines in a scan comparison.
5604fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5605    scan_delta
5606        .file_deltas
5607        .iter()
5608        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5609        .map(|f| {
5610            #[allow(clippy::cast_sign_loss)]
5611            let n = f.current_code as u64;
5612            n
5613        })
5614        .sum()
5615}
5616
5617fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5618    run.git_remote_url
5619        .as_deref()
5620        .zip(run.git_commit_long.as_deref())
5621        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5622}
5623
5624fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5625    run.git_remote_url
5626        .as_deref()
5627        .zip(run.git_branch.as_deref())
5628        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5629}
5630
5631fn scan_performed_by(run: &AnalysisRun) -> String {
5632    run.environment.ci_name.clone().unwrap_or_else(|| {
5633        format!(
5634            "{} / {}",
5635            run.environment.initiator_username, run.environment.initiator_hostname
5636        )
5637    })
5638}
5639
5640/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
5641fn build_lang_chart_json(run: &AnalysisRun) -> String {
5642    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5643    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5644    let entries: Vec<String> = langs
5645        .into_iter()
5646        .take(12)
5647        .map(|l| {
5648            let name = json_escape(l.language.display_name());
5649            format!(
5650                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5651                name,
5652                l.code_lines,
5653                l.comment_lines,
5654                l.blank_lines,
5655                l.total_physical_lines,
5656                l.functions,
5657                l.classes,
5658                l.variables,
5659                l.imports,
5660                l.files,
5661            )
5662        })
5663        .collect();
5664    format!("[{}]", entries.join(","))
5665}
5666
5667/// Per-language files-vs-lines points as a JSON array for the scatter chart.
5668fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5669    let entries: Vec<String> = run
5670        .totals_by_language
5671        .iter()
5672        .map(|l| {
5673            let name = json_escape(l.language.display_name());
5674            format!(
5675                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5676                name, l.files, l.code_lines, l.total_physical_lines,
5677            )
5678        })
5679        .collect();
5680    format!("[{}]", entries.join(","))
5681}
5682
5683/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
5684fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5685    let entries: Vec<String> = run
5686        .totals_by_language
5687        .iter()
5688        .filter(|l| {
5689            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5690        })
5691        .map(|l| {
5692            let name = json_escape(l.language.display_name());
5693            format!(
5694                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5695                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5696            )
5697        })
5698        .collect();
5699    format!("[{}]", entries.join(","))
5700}
5701
5702/// Per-submodule line counts as a JSON array for the submodule chart.
5703fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5704    let entries: Vec<String> = run
5705        .submodule_summaries
5706        .iter()
5707        .map(|s| {
5708            let name = json_escape(&s.name);
5709            format!(
5710                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5711                name,
5712                s.code_lines,
5713                s.comment_lines,
5714                s.blank_lines,
5715                s.total_physical_lines,
5716                s.files_analyzed,
5717            )
5718        })
5719        .collect();
5720    format!("[{}]", entries.join(","))
5721}
5722
5723/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
5724#[allow(clippy::cast_precision_loss)]
5725fn cov_pct_str(hit: u64, found: u64) -> String {
5726    if found > 0 {
5727        format!("{:.1}", hit as f64 / found as f64 * 100.0)
5728    } else {
5729        String::new()
5730    }
5731}
5732
5733/// `hit / found` summary string, or empty when nothing was found.
5734fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5735    if found > 0 {
5736        format!("{hit} / {found}")
5737    } else {
5738        String::new()
5739    }
5740}
5741
5742const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5743    use sloc_core::CocomoMode;
5744    match mode {
5745        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5746        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5747        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5748    }
5749}
5750
5751const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5752    use sloc_core::CocomoMode;
5753    match mode {
5754        CocomoMode::Organic => "Organic",
5755        CocomoMode::SemiDetached => "Semi-detached",
5756        CocomoMode::Embedded => "Embedded",
5757    }
5758}
5759
5760const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5761    use sloc_core::CocomoMode;
5762    match mode {
5763        CocomoMode::Organic => {
5764            "Organic: A small team working on a well-understood project in a familiar \
5765             environment with minimal external constraints. Suited for internal tools, \
5766             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5767        }
5768        CocomoMode::SemiDetached => {
5769            "Semi-detached: A mixed team with varying experience tackling a project with \
5770             moderate novelty and some rigid constraints. Typical for compilers, transaction \
5771             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5772        }
5773        CocomoMode::Embedded => {
5774            "Embedded: Tight hardware, software, or operational constraints requiring \
5775             significant innovation and deep integration work. Typical for real-time control \
5776             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5777        }
5778    }
5779}
5780
5781/// COCOMO display strings recomputed for the scan-wizard-selected mode.
5782struct CocomoFields {
5783    has_cocomo: bool,
5784    effort_str: String,
5785    duration_str: String,
5786    staff_str: String,
5787    ksloc_str: String,
5788    mode_label: String,
5789    mode_tooltip: String,
5790}
5791
5792#[allow(clippy::cast_precision_loss)]
5793fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5794    use sloc_core::CocomoMode;
5795    let mode = match mode_str {
5796        "semi_detached" => CocomoMode::SemiDetached,
5797        "embedded" => CocomoMode::Embedded,
5798        _ => CocomoMode::Organic,
5799    };
5800    let (a, b, c, d) = cocomo_coefficients(mode);
5801    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5802    let effort = a * ksloc.powf(b);
5803    let duration = c * effort.powf(d);
5804    let staff = if duration > 0.0 {
5805        effort / duration
5806    } else {
5807        0.0
5808    };
5809    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5810    let mode_label = cocomo_mode_label(mode).to_string();
5811    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5812    if run.summary_totals.code_lines > 0 {
5813        CocomoFields {
5814            has_cocomo: true,
5815            effort_str: round2(effort),
5816            duration_str: round2(duration),
5817            staff_str: round2(staff),
5818            ksloc_str: round2(ksloc),
5819            mode_label,
5820            mode_tooltip,
5821        }
5822    } else {
5823        CocomoFields {
5824            has_cocomo: false,
5825            effort_str: String::new(),
5826            duration_str: String::new(),
5827            staff_str: String::new(),
5828            ksloc_str: String::new(),
5829            mode_label,
5830            mode_tooltip,
5831        }
5832    }
5833}
5834
5835#[allow(clippy::too_many_lines)]
5836#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
5837#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
5838fn render_result_page(
5839    run: &AnalysisRun,
5840    artifacts: &RunArtifacts,
5841    run_id: &str,
5842    csp_nonce: &str,
5843    confluence_configured: bool,
5844    server_mode: bool,
5845) -> Response {
5846    let ctx = &artifacts.result_context;
5847    let prev_entry = &ctx.prev_entry;
5848    let prev_scan_count = ctx.prev_scan_count;
5849    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
5850    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
5851    // field is never blank.
5852    let project_path_owned = if ctx.project_path.is_empty() {
5853        run.input_roots.join(", ")
5854    } else {
5855        ctx.project_path.clone()
5856    };
5857    let project_path = &project_path_owned;
5858
5859    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5860        prev.json_path
5861            .as_ref()
5862            .and_then(|p| read_json(p).ok())
5863            .map(|prev_run| compute_delta(&prev_run, run))
5864    });
5865
5866    let files_analyzed = run.per_file_records.len() as u64;
5867    let files_skipped = run.skipped_file_records.len() as u64;
5868    let totals = sum_lang_totals(run);
5869
5870    let DeltaFields {
5871        prev_fa_str,
5872        prev_fs_str,
5873        prev_pl_str,
5874        prev_cl_str,
5875        prev_cml_str,
5876        prev_bl_str,
5877        delta_fa_str,
5878        delta_fa_class,
5879        delta_fs_str,
5880        delta_fs_class,
5881        delta_pl_str,
5882        delta_pl_class,
5883        delta_cl_str,
5884        delta_cl_class,
5885        delta_cml_str,
5886        delta_cml_class,
5887        delta_bl_str,
5888        delta_bl_class,
5889        delta_lines_added,
5890        delta_lines_removed,
5891        delta_lines_net_str,
5892        delta_lines_net_class,
5893    } = compute_delta_fields(
5894        prev_entry.as_ref(),
5895        &totals,
5896        files_analyzed,
5897        files_skipped,
5898        scan_delta.as_ref(),
5899    );
5900
5901    let run_dir = artifacts.output_dir.clone();
5902    let git_branch = run.git_branch.clone();
5903    let git_commit = run.git_commit_short.clone();
5904    let git_commit_long = run.git_commit_long.clone();
5905    let git_author = run.git_commit_author.clone();
5906    let git_commit_url = git_commit_url_for(run);
5907    let git_branch_url = git_branch_url_for(run);
5908    let scan_performed_by = scan_performed_by(run);
5909    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5910    let os_display = format!(
5911        "{} / {}",
5912        run.environment.operating_system, run.environment.architecture
5913    );
5914    let test_count = run.summary_totals.test_count;
5915
5916    // ── New metrics ──────────────────────────────────────────────────────────
5917    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5918    let lsloc = run.summary_totals.lsloc;
5919    let uloc = run.uloc;
5920    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5921    let duplicate_group_count = run.duplicate_groups.len();
5922
5923    // Re-compute COCOMO with the mode selected in the scan wizard.
5924    let ctx = &artifacts.result_context;
5925    let CocomoFields {
5926        has_cocomo,
5927        effort_str: cocomo_effort_str,
5928        duration_str: cocomo_duration_str,
5929        staff_str: cocomo_staff_str,
5930        ksloc_str: cocomo_ksloc_str,
5931        mode_label: cocomo_mode_label,
5932        mode_tooltip: cocomo_mode_tooltip,
5933    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
5934    let complexity_alert = ctx.complexity_alert;
5935
5936    let template = ResultTemplate {
5937        version: env!("CARGO_PKG_VERSION"),
5938        report_title: run.effective_configuration.reporting.report_title.clone(),
5939        project_path: project_path.clone(),
5940        output_dir: display_path(&artifacts.output_dir),
5941        run_id: run_id.to_owned(),
5942        run_id_short: run_id
5943            .split('-')
5944            .next_back()
5945            .unwrap_or(run_id)
5946            .chars()
5947            .take(7)
5948            .collect(),
5949        files_analyzed,
5950        files_skipped,
5951        physical_lines: totals.physical_lines,
5952        code_lines: totals.code_lines,
5953        comment_lines: totals.comment_lines,
5954        blank_lines: totals.blank_lines,
5955        mixed_lines: totals.mixed_lines,
5956        functions: totals.functions,
5957        classes: totals.classes,
5958        variables: totals.variables,
5959        imports: totals.imports,
5960        html_url: artifacts
5961            .html_path
5962            .as_ref()
5963            .map(|_| format!("/runs/html/{run_id}")),
5964        pdf_url: artifacts
5965            .pdf_path
5966            .as_ref()
5967            .map(|_| format!("/runs/pdf/{run_id}")),
5968        json_url: artifacts
5969            .json_path
5970            .as_ref()
5971            .map(|_| format!("/runs/json/{run_id}")),
5972        html_download_url: artifacts
5973            .html_path
5974            .as_ref()
5975            .map(|_| format!("/runs/html/{run_id}?download=1")),
5976        pdf_download_url: artifacts
5977            .pdf_path
5978            .as_ref()
5979            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5980        json_download_url: artifacts
5981            .json_path
5982            .as_ref()
5983            .map(|_| format!("/runs/json/{run_id}?download=1")),
5984        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5985        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5986        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5987        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5988        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5989        prev_fa_str,
5990        prev_fs_str,
5991        prev_pl_str,
5992        prev_cl_str,
5993        prev_cml_str,
5994        prev_bl_str,
5995        delta_fa_str,
5996        delta_fa_class,
5997        delta_fs_str,
5998        delta_fs_class,
5999        delta_pl_str,
6000        delta_pl_class,
6001        delta_cl_str,
6002        delta_cl_class,
6003        delta_cml_str,
6004        delta_cml_class,
6005        delta_bl_str,
6006        delta_bl_class,
6007        delta_lines_added,
6008        delta_lines_removed,
6009        delta_lines_net_str,
6010        delta_lines_net_class,
6011        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6012        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6013        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6014        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6015        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6016        git_branch,
6017        git_branch_url,
6018        git_commit,
6019        git_commit_long,
6020        git_author,
6021        git_commit_url,
6022        scan_performed_by,
6023        scan_time_display,
6024        os_display,
6025        test_count,
6026        test_assertion_count: run.summary_totals.test_assertion_count,
6027        current_scan_number: prev_scan_count + 1,
6028        prev_scan_count,
6029        submodule_rows: run
6030            .submodule_summaries
6031            .iter()
6032            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6033            .collect(),
6034        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6035        scan_config_url: format!("/runs/scan-config/{run_id}"),
6036        lang_chart_json: build_lang_chart_json(run),
6037        scatter_chart_json: build_scatter_chart_json(run),
6038        semantic_chart_json: build_semantic_chart_json(run),
6039        submodule_chart_json: build_submodule_chart_json(run),
6040        has_submodule_data: !run.submodule_summaries.is_empty(),
6041        has_semantic_data: run
6042            .totals_by_language
6043            .iter()
6044            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6045        csp_nonce: csp_nonce.to_owned(),
6046        confluence_configured,
6047        server_mode,
6048        report_header_footer: run
6049            .effective_configuration
6050            .reporting
6051            .report_header_footer
6052            .clone(),
6053        is_offline: false,
6054        cyclomatic_complexity,
6055        lsloc,
6056        uloc,
6057        dryness_pct_str,
6058        duplicate_group_count,
6059        has_cocomo,
6060        cocomo_effort_str,
6061        cocomo_duration_str,
6062        cocomo_staff_str,
6063        cocomo_ksloc_str,
6064        cocomo_mode_label,
6065        cocomo_mode_tooltip,
6066        complexity_alert,
6067        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6068        cov_line_pct: cov_pct_str(
6069            run.summary_totals.coverage_lines_hit,
6070            run.summary_totals.coverage_lines_found,
6071        ),
6072        cov_fn_pct: cov_pct_str(
6073            run.summary_totals.coverage_functions_hit,
6074            run.summary_totals.coverage_functions_found,
6075        ),
6076        cov_branch_pct: cov_pct_str(
6077            run.summary_totals.coverage_branches_hit,
6078            run.summary_totals.coverage_branches_found,
6079        ),
6080        cov_lines_summary: cov_lines_summary_str(
6081            run.summary_totals.coverage_lines_hit,
6082            run.summary_totals.coverage_lines_found,
6083        ),
6084    };
6085
6086    Html(
6087        template
6088            .render()
6089            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6090    )
6091    .into_response()
6092}
6093
6094fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6095    let slug: String = report_title
6096        .chars()
6097        .map(|c| {
6098            if c.is_alphanumeric() || c == '-' {
6099                c.to_ascii_lowercase()
6100            } else {
6101                '_'
6102            }
6103        })
6104        .collect::<String>()
6105        .split('_')
6106        .filter(|s| !s.is_empty())
6107        .collect::<Vec<_>>()
6108        .join("_");
6109
6110    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6111
6112    if slug.is_empty() {
6113        format!("report_{short_id}.pdf")
6114    } else {
6115        format!("{slug}_{short_id}.pdf")
6116    }
6117}
6118
6119#[derive(Serialize)]
6120struct PdfStatusResponse {
6121    ready: bool,
6122}
6123
6124/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6125/// Clients poll this to update the button state without page reloads.
6126async fn pdf_status_handler(
6127    State(state): State<AppState>,
6128    AxumPath(run_id): AxumPath<String>,
6129) -> Response {
6130    let pdf_path = {
6131        let registry = state.artifacts.lock().await;
6132        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6133    };
6134    let pdf_path = if pdf_path.is_some() {
6135        pdf_path
6136    } else {
6137        let reg = state.registry.lock().await;
6138        reg.find_by_run_id(&run_id)
6139            .map(recover_artifacts_from_registry)
6140            .and_then(|a| a.pdf_path)
6141    };
6142    let ready = pdf_path.is_some_and(|p| p.exists());
6143    Json(PdfStatusResponse { ready }).into_response()
6144}
6145
6146/// GET /`api/runs/:run_id/bundle`
6147///
6148/// Streams a gzip-compressed tar archive containing every artifact in the run's
6149/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6150/// is built in memory so it never touches a temp file.
6151async fn download_bundle_handler(
6152    State(state): State<AppState>,
6153    AxumPath(run_id): AxumPath<String>,
6154) -> Response {
6155    // Resolve output directory from in-memory cache or persisted registry.
6156    let output_dir = {
6157        let cache = state.artifacts.lock().await;
6158        cache.get(&run_id).map(|a| a.output_dir.clone())
6159    };
6160    let output_dir = if let Some(d) = output_dir {
6161        d
6162    } else {
6163        let reg = state.registry.lock().await;
6164        match reg.find_by_run_id(&run_id) {
6165            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6166            None => {
6167                return (
6168                    StatusCode::NOT_FOUND,
6169                    Json(serde_json::json!({"error": "Run not found"})),
6170                )
6171                    .into_response();
6172            }
6173        }
6174    };
6175
6176    if !output_dir.exists() {
6177        return (
6178            StatusCode::NOT_FOUND,
6179            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6180        )
6181            .into_response();
6182    }
6183
6184    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6185    let run_id_clone = run_id.clone();
6186    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6187        use flate2::{write::GzEncoder, Compression};
6188        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6189        {
6190            let mut tar = tar::Builder::new(&mut enc);
6191            tar.follow_symlinks(false);
6192            // Append every regular file in the output directory, skipping
6193            // sub-directories (the output dir is always flat).
6194            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6195                for entry in entries.filter_map(Result::ok) {
6196                    let p = entry.path();
6197                    if p.is_file() {
6198                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6199                        let archive_path = format!("{run_id_clone}/{name}");
6200                        tar.append_path_with_name(&p, &archive_path)?;
6201                    }
6202                }
6203            }
6204            tar.finish()?;
6205        }
6206        Ok(enc.finish()?)
6207    })
6208    .await;
6209
6210    match archive_result {
6211        Ok(Ok(bytes)) => {
6212            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6213            axum::response::Response::builder()
6214                .status(StatusCode::OK)
6215                .header("Content-Type", "application/gzip")
6216                .header(
6217                    "Content-Disposition",
6218                    format!("attachment; filename=\"{filename}\""),
6219                )
6220                .header("Content-Length", bytes.len().to_string())
6221                .body(axum::body::Body::from(bytes))
6222                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6223        }
6224        Ok(Err(e)) => (
6225            StatusCode::INTERNAL_SERVER_ERROR,
6226            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6227        )
6228            .into_response(),
6229        Err(e) => (
6230            StatusCode::INTERNAL_SERVER_ERROR,
6231            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6232        )
6233            .into_response(),
6234    }
6235}
6236
6237/// DELETE /`api/runs/:run_id`
6238///
6239/// Removes all on-disk artifacts for the run and purges the run from the
6240/// in-memory cache and the persisted registry. Returns 204 on success.
6241async fn delete_run_handler(
6242    State(state): State<AppState>,
6243    AxumPath(run_id): AxumPath<String>,
6244) -> Response {
6245    // Resolve output directory.
6246    let output_dir = {
6247        let mut cache = state.artifacts.lock().await;
6248        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6249        cache.remove(&run_id);
6250        dir
6251    };
6252    let output_dir = if let Some(d) = output_dir {
6253        d
6254    } else {
6255        let reg = state.registry.lock().await;
6256        reg.find_by_run_id(&run_id)
6257            .map(|e| recover_artifacts_from_registry(e).output_dir)
6258            .unwrap_or_default()
6259    };
6260
6261    // Remove from persisted registry.
6262    {
6263        let mut reg = state.registry.lock().await;
6264        reg.entries.retain(|e| e.run_id != run_id);
6265        let _ = reg.save(&state.registry_path);
6266    }
6267
6268    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6269    // a prior delete may have already removed the directory.
6270    if output_dir.exists() {
6271        match tokio::fs::remove_dir_all(&output_dir).await {
6272            Ok(()) => {}
6273            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6274            Err(e) => {
6275                return (
6276                    StatusCode::INTERNAL_SERVER_ERROR,
6277                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6278                )
6279                    .into_response();
6280            }
6281        }
6282    }
6283
6284    StatusCode::NO_CONTENT.into_response()
6285}
6286
6287/// POST /api/runs/cleanup
6288///
6289/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6290/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6291async fn cleanup_runs_handler(
6292    State(state): State<AppState>,
6293    Json(body): Json<serde_json::Value>,
6294) -> Response {
6295    let days = body
6296        .get("older_than_days")
6297        .and_then(serde_json::Value::as_u64)
6298        .unwrap_or(30)
6299        .max(1);
6300
6301    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6302
6303    // Collect expired entries from the registry.
6304    let expired: Vec<(String, PathBuf)> = {
6305        let reg = state.registry.lock().await;
6306        reg.entries
6307            .iter()
6308            .filter(|e| e.timestamp_utc < cutoff)
6309            .map(|e| {
6310                let arts = recover_artifacts_from_registry(e);
6311                (e.run_id.clone(), arts.output_dir)
6312            })
6313            .collect()
6314    };
6315
6316    let mut deleted = 0usize;
6317    for (run_id, output_dir) in &expired {
6318        // Remove from in-memory cache.
6319        state.artifacts.lock().await.remove(run_id);
6320        // Delete on-disk artifacts (non-fatal if already gone).
6321        if output_dir.exists() {
6322            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6323                eprintln!(
6324                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6325                    output_dir.display()
6326                );
6327                continue;
6328            }
6329        }
6330        deleted += 1;
6331    }
6332
6333    // Purge expired run IDs from the registry in one pass.
6334    let expired_ids: std::collections::HashSet<&str> =
6335        expired.iter().map(|(id, _)| id.as_str()).collect();
6336    {
6337        let mut reg = state.registry.lock().await;
6338        reg.entries
6339            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6340        let _ = reg.save(&state.registry_path);
6341    }
6342
6343    Json(serde_json::json!({ "deleted": deleted })).into_response()
6344}
6345
6346/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6347/// abort it when the policy is updated or disabled.
6348fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6349    tokio::spawn(async move {
6350        loop {
6351            let interval_secs = {
6352                let store = state.cleanup_policy.lock().await;
6353                match &store.policy {
6354                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6355                    _ => break,
6356                }
6357            };
6358            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6359            let n = run_auto_cleanup(&state).await;
6360            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6361        }
6362    })
6363}
6364
6365fn collect_runs_to_delete(
6366    reg: &ScanRegistry,
6367    max_age_days: Option<u32>,
6368    max_run_count: Option<u32>,
6369) -> std::collections::HashSet<String> {
6370    let mut to_delete = std::collections::HashSet::new();
6371    if let Some(days) = max_age_days {
6372        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6373        for e in &reg.entries {
6374            if e.timestamp_utc < cutoff {
6375                to_delete.insert(e.run_id.clone());
6376            }
6377        }
6378    }
6379    if let Some(max_count) = max_run_count {
6380        // entries are sorted newest-first; skip the ones we keep
6381        for e in reg.entries.iter().skip(max_count as usize) {
6382            to_delete.insert(e.run_id.clone());
6383        }
6384    }
6385    to_delete
6386}
6387
6388async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6389    let output_dir = {
6390        let mut cache = state.artifacts.lock().await;
6391        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6392        cache.remove(run_id);
6393        d
6394    };
6395    let output_dir = if let Some(d) = output_dir {
6396        d
6397    } else {
6398        let reg = state.registry.lock().await;
6399        reg.find_by_run_id(run_id)
6400            .map(|e| recover_artifacts_from_registry(e).output_dir)
6401            .unwrap_or_default()
6402    };
6403    if output_dir.exists() {
6404        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6405    }
6406}
6407
6408/// Core cleanup logic shared by the background task and the "Run Now" handler.
6409/// Applies both the age limit and the count limit, then updates `last_run_at`.
6410/// Returns the number of runs deleted.
6411async fn run_auto_cleanup(state: &AppState) -> u32 {
6412    let (max_age_days, max_run_count) = {
6413        let store = state.cleanup_policy.lock().await;
6414        match &store.policy {
6415            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6416            _ => return 0,
6417        }
6418    };
6419
6420    let to_delete = {
6421        let reg = state.registry.lock().await;
6422        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6423    };
6424
6425    for run_id in &to_delete {
6426        delete_run_artifacts(state, run_id).await;
6427    }
6428
6429    // Purge from registry.
6430    if !to_delete.is_empty() {
6431        let mut reg = state.registry.lock().await;
6432        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6433        let _ = reg.save(&state.registry_path);
6434    }
6435
6436    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6437    {
6438        let mut store = state.cleanup_policy.lock().await;
6439        store.last_run_at = Some(chrono::Utc::now());
6440        store.last_run_deleted = Some(deleted);
6441        let _ = store.save(&state.cleanup_policy_path);
6442    }
6443    deleted
6444}
6445
6446// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6447
6448/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6449async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6450    let store = state.cleanup_policy.lock().await;
6451    Json(serde_json::json!({
6452        "policy": store.policy,
6453        "last_run_at": store.last_run_at,
6454        "last_run_deleted": store.last_run_deleted,
6455    }))
6456    .into_response()
6457}
6458
6459/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6460async fn api_save_cleanup_policy(
6461    State(state): State<AppState>,
6462    Json(body): Json<CleanupPolicy>,
6463) -> Response {
6464    // Abort any running task so the new interval takes effect immediately.
6465    {
6466        let mut handle = state.cleanup_task_handle.lock().await;
6467        if let Some(h) = handle.take() {
6468            h.abort();
6469        }
6470    }
6471    {
6472        let mut store = state.cleanup_policy.lock().await;
6473        store.policy = Some(body.clone());
6474        if let Err(e) = store.save(&state.cleanup_policy_path) {
6475            return (
6476                StatusCode::INTERNAL_SERVER_ERROR,
6477                Json(serde_json::json!({"error": e.to_string()})),
6478            )
6479                .into_response();
6480        }
6481    }
6482    if body.enabled {
6483        let handle = spawn_cleanup_policy_task(state.clone());
6484        *state.cleanup_task_handle.lock().await = Some(handle);
6485    }
6486    StatusCode::NO_CONTENT.into_response()
6487}
6488
6489/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6490async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6491    let deleted = run_auto_cleanup(&state).await;
6492    Json(serde_json::json!({ "deleted": deleted })).into_response()
6493}
6494
6495/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6496async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6497    {
6498        let mut handle = state.cleanup_task_handle.lock().await;
6499        if let Some(h) = handle.take() {
6500            h.abort();
6501        }
6502    }
6503    {
6504        let mut store = state.cleanup_policy.lock().await;
6505        store.policy = None;
6506        let _ = store.save(&state.cleanup_policy_path);
6507    }
6508    StatusCode::NO_CONTENT.into_response()
6509}
6510
6511/// Serve the HTML artifact for a run — view or download.
6512/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6513/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6514/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6515/// Only called for browser views; downloads keep the self-contained inline version.
6516fn swap_inline_chart_js_for_static(html: String) -> String {
6517    let Some(head_end) = html.find("</head>") else {
6518        return html;
6519    };
6520    let Some(script_start) = html[..head_end].rfind("<script") else {
6521        return html;
6522    };
6523    let Some(close_offset) = html[script_start..].find("</script>") else {
6524        return html;
6525    };
6526    let block_end = script_start + close_offset + "</script>".len();
6527    format!(
6528        "{}<script src=\"/static/chart-report.js\"></script>{}",
6529        &html[..script_start],
6530        &html[block_end..]
6531    )
6532}
6533
6534/// current-request Content-Security-Policy nonce check.
6535fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6536    // Find the first nonce value that was baked in at render time.
6537    let Some(start) = html.find("nonce=\"") else {
6538        // Reports generated before nonce support was added have bare <style> and <script>
6539        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6540        // the inline blocks — without it the browser blocks all CSS and JS.
6541        return html
6542            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6543            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6544    };
6545    let value_start = start + 7; // len(r#"nonce=""#) == 7
6546    let Some(end_offset) = html[value_start..].find('"') else {
6547        return html.to_owned();
6548    };
6549    let old_nonce = &html[value_start..value_start + end_offset];
6550    html.replace(
6551        &format!("nonce=\"{old_nonce}\""),
6552        &format!("nonce=\"{new_nonce}\""),
6553    )
6554}
6555
6556fn serve_html_artifact(
6557    path: &Path,
6558    wants_download: bool,
6559    csp_nonce: &str,
6560    run_id: &str,
6561    server_mode: bool,
6562) -> Response {
6563    match fs::read_to_string(path) {
6564        Ok(raw) => {
6565            // Patch the saved nonce so inline styles/scripts pass CSP.
6566            let content = patch_html_nonce(&raw, csp_nonce);
6567            if wants_download {
6568                // Keep the self-contained inline version for downloads (opened as file://).
6569                (
6570                    [
6571                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6572                        (
6573                            header::CONTENT_DISPOSITION,
6574                            "attachment; filename=report.html",
6575                        ),
6576                    ],
6577                    content,
6578                )
6579                    .into_response()
6580            } else {
6581                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
6582                // browser caches it after the first view; the HTML response also shrinks.
6583                Html(swap_inline_chart_js_for_static(content)).into_response()
6584            }
6585        }
6586        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6587            let filename = path.file_name().map_or_else(
6588                || "report.html".to_string(),
6589                |n| n.to_string_lossy().into_owned(),
6590            );
6591            let html = LocateFileTemplate {
6592                run_id: run_id.to_owned(),
6593                artifact_type: "html".to_string(),
6594                expected_filename: filename,
6595                server_mode,
6596                csp_nonce: csp_nonce.to_owned(),
6597                version: env!("CARGO_PKG_VERSION"),
6598            }
6599            .render()
6600            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6601            (StatusCode::NOT_FOUND, Html(html)).into_response()
6602        }
6603        Err(err) => {
6604            let filename = path.file_name().map_or_else(
6605                || "report.html".to_string(),
6606                |n| n.to_string_lossy().into_owned(),
6607            );
6608            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6609            let html = ErrorTemplate {
6610                message: msg,
6611                last_report_url: Some("/view-reports".to_string()),
6612                last_report_label: Some("View Reports".to_string()),
6613                run_id: None,
6614                error_code: Some(404),
6615                csp_nonce: csp_nonce.to_owned(),
6616                version: env!("CARGO_PKG_VERSION"),
6617            }
6618            .render()
6619            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6620            (StatusCode::NOT_FOUND, Html(html)).into_response()
6621        }
6622    }
6623}
6624
6625/// Serve the PDF artifact for a run — inline or download.
6626fn serve_pdf_artifact(
6627    path: &Path,
6628    report_title: &str,
6629    run_id: &str,
6630    wants_download: bool,
6631    csp_nonce: &str,
6632) -> Response {
6633    match fs::read(path) {
6634        Ok(bytes) => {
6635            let filename = build_pdf_filename(report_title, run_id);
6636            let disposition = if wants_download {
6637                format!("attachment; filename=\"{filename}\"")
6638            } else {
6639                format!("inline; filename=\"{filename}\"")
6640            };
6641            (
6642                [
6643                    (header::CONTENT_TYPE, "application/pdf".to_string()),
6644                    (header::CONTENT_DISPOSITION, disposition),
6645                ],
6646                bytes,
6647            )
6648                .into_response()
6649        }
6650        Err(err) => {
6651            let filename = path.file_name().map_or_else(
6652                || "report.pdf".to_string(),
6653                |n| n.to_string_lossy().into_owned(),
6654            );
6655            let msg = format!(
6656                "PDF report '{filename}' could not be read.\n\n\
6657                 Error: {err}\n\n\
6658                 If you moved or renamed the output folder, the stored path is now stale. \
6659                 Use 'Open PDF folder' from the results page to browse the output directory."
6660            );
6661            let html = ErrorTemplate {
6662                message: msg,
6663                last_report_url: Some("/view-reports".to_string()),
6664                last_report_label: Some("View Reports".to_string()),
6665                run_id: Some(run_id.to_owned()),
6666                error_code: Some(404),
6667                csp_nonce: csp_nonce.to_owned(),
6668                version: env!("CARGO_PKG_VERSION"),
6669            }
6670            .render()
6671            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6672            (StatusCode::NOT_FOUND, Html(html)).into_response()
6673        }
6674    }
6675}
6676
6677/// Serve the JSON artifact for a run — view or download.
6678fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6679    match fs::read(path) {
6680        Ok(bytes) => {
6681            if wants_download {
6682                (
6683                    [
6684                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6685                        (
6686                            header::CONTENT_DISPOSITION,
6687                            "attachment; filename=result.json",
6688                        ),
6689                    ],
6690                    bytes,
6691                )
6692                    .into_response()
6693            } else {
6694                (
6695                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6696                    bytes,
6697                )
6698                    .into_response()
6699            }
6700        }
6701        Err(err) => {
6702            let filename = path.file_name().map_or_else(
6703                || "result.json".to_string(),
6704                |n| n.to_string_lossy().into_owned(),
6705            );
6706            let msg = format!(
6707                "JSON result '{filename}' could not be read.\n\n\
6708                 Error: {err}\n\n\
6709                 If you moved or renamed the output folder, the stored path is now stale. \
6710                 Use 'Open JSON folder' from the results page to browse the output directory."
6711            );
6712            let html = ErrorTemplate {
6713                message: msg,
6714                last_report_url: Some("/view-reports".to_string()),
6715                last_report_label: Some("View Reports".to_string()),
6716                run_id: None,
6717                error_code: Some(404),
6718                csp_nonce: csp_nonce.to_owned(),
6719                version: env!("CARGO_PKG_VERSION"),
6720            }
6721            .render()
6722            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6723            (StatusCode::NOT_FOUND, Html(html)).into_response()
6724        }
6725    }
6726}
6727
6728/// Recover a `RunArtifacts` from the persisted registry for a run ID.
6729fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6730    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
6731    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
6732    let output_dir = entry
6733        .html_path
6734        .as_ref()
6735        .or(entry.json_path.as_ref())
6736        .or(entry.pdf_path.as_ref())
6737        .or(entry.csv_path.as_ref())
6738        .or(entry.xlsx_path.as_ref())
6739        .and_then(|p| {
6740            let parent = p.parent()?;
6741            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6742            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
6743            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6744                parent.parent().map(PathBuf::from)
6745            } else {
6746                Some(parent.to_path_buf())
6747            }
6748        })
6749        .unwrap_or_default();
6750    // Recover pdf_path: use the persisted one, or look for report.pdf
6751    // adjacent to html/json if only the old entries lack it.
6752    let pdf_path = entry.pdf_path.clone().or_else(|| {
6753        let candidate = output_dir.join("report.pdf");
6754        candidate.exists().then_some(candidate)
6755    });
6756    // csv_path / xlsx_path: persisted paths take precedence; fall back to
6757    // scanning the run directory for files matching the expected patterns so
6758    // that runs created before this feature still surface their artifacts.
6759    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6760        // Check excel/ subfolder (new layout) then root (old layout).
6761        for dir in &[output_dir.join("excel"), output_dir.clone()] {
6762            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6763                entries
6764                    .filter_map(std::result::Result::ok)
6765                    .find(|e| {
6766                        let n = e.file_name();
6767                        let n = n.to_string_lossy();
6768                        n.starts_with("report_") && n.ends_with(ext)
6769                    })
6770                    .map(|e| e.path())
6771            }) {
6772                return Some(p);
6773            }
6774        }
6775        None
6776    };
6777
6778    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6779    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6780    RunArtifacts {
6781        output_dir: output_dir.clone(),
6782        html_path: entry.html_path.clone(),
6783        pdf_path,
6784        json_path: entry.json_path.clone(),
6785        csv_path,
6786        xlsx_path,
6787        scan_config_path: find_scan_config_in_dir(&output_dir),
6788        report_title: entry.project_label.clone(),
6789        result_context: RunResultContext::default(),
6790    }
6791}
6792
6793#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6794async fn resolve_artifact_set(
6795    state: &AppState,
6796    run_id: &str,
6797    csp_nonce: &str,
6798) -> Result<RunArtifacts, Response> {
6799    let cached = state.artifacts.lock().await.get(run_id).cloned();
6800    if let Some(a) = cached {
6801        return Ok(a);
6802    }
6803    let reg = state.registry.lock().await;
6804    if let Some(entry) = reg.find_by_run_id(run_id) {
6805        return Ok(recover_artifacts_from_registry(entry));
6806    }
6807    drop(reg);
6808    let short_id = &run_id[..run_id.len().min(8)];
6809    let hint = if matches!(
6810        run_id,
6811        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6812    ) {
6813        format!(
6814            " The URL format appears to be reversed \u{2014} \
6815             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6816             Use the View Reports page to navigate to your scan."
6817        )
6818    } else {
6819        " The report may have been deleted or the report directory moved. \
6820         Use View Reports to browse your scan history."
6821            .to_string()
6822    };
6823    let error_html = ErrorTemplate {
6824        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6825        last_report_url: Some("/view-reports".to_string()),
6826        last_report_label: Some("View Reports".to_string()),
6827        run_id: None,
6828        error_code: Some(404),
6829        csp_nonce: csp_nonce.to_owned(),
6830        version: env!("CARGO_PKG_VERSION"),
6831    }
6832    .render()
6833    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6834    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6835}
6836
6837/// Return the path to a run's PDF, queuing background generation when it is missing.
6838///
6839/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6840/// Returns `Err(response)` when there is no JSON source to regenerate from.
6841async fn resolve_or_queue_pdf(
6842    state: &AppState,
6843    pdf_path: Option<PathBuf>,
6844    json_path: Option<PathBuf>,
6845    output_dir: PathBuf,
6846    run_id: &str,
6847    report_title: &str,
6848    csp_nonce: &str,
6849) -> Result<PathBuf, Response> {
6850    if let Some(p) = pdf_path {
6851        return Ok(p);
6852    }
6853    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6854        let msg = "PDF report was not generated for this run. \
6855                   Re-run the analysis with PDF output enabled."
6856            .to_string();
6857        let html = ErrorTemplate {
6858            message: msg,
6859            last_report_url: Some(format!("/runs/html/{run_id}")),
6860            last_report_label: Some("View HTML Report".to_string()),
6861            run_id: Some(run_id.to_string()),
6862            error_code: Some(404),
6863            csp_nonce: csp_nonce.to_string(),
6864            version: env!("CARGO_PKG_VERSION"),
6865        }
6866        .render()
6867        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6868        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6869    };
6870    let pdf_filename = build_pdf_filename(report_title, run_id);
6871    let pdf_dest = output_dir.join(&pdf_filename);
6872    if !pdf_dest.exists() {
6873        // Record the pending path so concurrent requests show the spinner.
6874        {
6875            let mut map = state.artifacts.lock().await;
6876            if let Some(entry) = map.get_mut(run_id) {
6877                entry.pdf_path = Some(pdf_dest.clone());
6878            }
6879        }
6880        {
6881            let mut reg = state.registry.lock().await;
6882            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6883                e.pdf_path = Some(pdf_dest.clone());
6884            }
6885            let _ = reg.save(&state.registry_path);
6886        }
6887        spawn_native_pdf_background(
6888            json_src,
6889            pdf_dest.clone(),
6890            run_id.to_string(),
6891            state.artifacts.clone(),
6892        );
6893    }
6894    Ok(pdf_dest)
6895}
6896
6897/// Self-refreshing "please wait" page shown while the background PDF task is still running.
6898fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6899    let html = format!(
6900                    "<!doctype html><html lang=\"en\"><head>\
6901                     <meta charset=utf-8>\
6902                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6903                     <meta http-equiv=\"refresh\" content=\"5\">\
6904                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
6905                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6906                     <style nonce=\"{csp_nonce}\">\
6907                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6908                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6909                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6910                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6911                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6912                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6913                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6914                     background:var(--bg);color:var(--text);}}\
6915                     .top-nav{{position:sticky;top:0;z-index:30;\
6916                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6917                     border-bottom:1px solid rgba(255,255,255,0.12);\
6918                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6919                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6920                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
6921                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6922                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6923                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6924                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6925                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6926                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6927                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6928                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6929                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6930                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6931                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6932                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6933                     justify-content:center;min-height:38px;border-radius:999px;\
6934                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6935                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6936                     .theme-toggle .icon-sun{{display:none;}}\
6937                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6938                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6939                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6940                     display:flex;align-items:center;justify-content:center;\
6941                     min-height:calc(100vh - 56px);}}\
6942                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6943                     .panel{{background:var(--surface);border:1px solid var(--line);\
6944                     border-radius:var(--radius);box-shadow:var(--shadow);\
6945                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6946                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
6947                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
6948                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6949                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6950                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6951                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6952                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6953                     min-height:42px;padding:0 20px;border-radius:14px;\
6954                     border:1px solid var(--line-strong);text-decoration:none;\
6955                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6956                     .back-link:hover{{background:var(--line);}}\
6957                     </style></head>\
6958                     <body>\
6959                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6960                       <a class=\"brand\" href=\"/\">\
6961                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6962                         <div class=\"brand-copy\">\
6963                           <div class=\"brand-title\">OxideSLOC</div>\
6964                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6965                         </div>\
6966                       </a>\
6967                       <div class=\"nav-right\">\
6968                         <a class=\"nav-pill\" href=\"/\">Home</a>\
6969                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6970                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6971                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6972                           <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>\
6973                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6974                           <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>\
6975                         </button>\
6976                       </div>\
6977                     </div></div>\
6978                     <div class=\"page\"><div class=\"panel\">\
6979                       <div class=\"spin-ring\"></div>\
6980                       <h1>Generating PDF\u{2026}</h1>\
6981                       <p>The PDF is being generated from the scan results.<br>\
6982                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
6983                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6984                     </div></div>\
6985                     <script nonce=\"{csp_nonce}\">\
6986                     (function(){{\
6987                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6988                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
6989                       var t=document.getElementById(\"theme-toggle\");\
6990                       if(t)t.addEventListener(\"click\",function(){{\
6991                         var d=b.classList.toggle(\"dark-theme\");\
6992                         localStorage.setItem(k,d?\"dark\":\"light\");\
6993                       }});\
6994                     }})();\
6995                     </script>\
6996                     </body></html>"
6997    );
6998    Html(html).into_response()
6999}
7000
7001/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7002fn render_error_artifact_html(
7003    message: String,
7004    last_report_url: Option<String>,
7005    last_report_label: Option<String>,
7006    run_id: Option<String>,
7007    error_code: Option<u16>,
7008    csp_nonce: &str,
7009) -> String {
7010    ErrorTemplate {
7011        message,
7012        last_report_url,
7013        last_report_label,
7014        run_id,
7015        error_code,
7016        csp_nonce: csp_nonce.to_owned(),
7017        version: env!("CARGO_PKG_VERSION"),
7018    }
7019    .render()
7020    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7021}
7022
7023/// Read a file and serve it as an attachment download.
7024fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7025    fs::read(path).map_or_else(
7026        |_| StatusCode::NOT_FOUND.into_response(),
7027        |bytes| {
7028            let filename = path.file_name().map_or_else(
7029                || fallback_filename.to_string(),
7030                |n| n.to_string_lossy().into_owned(),
7031            );
7032            (
7033                [
7034                    (header::CONTENT_TYPE, content_type.to_string()),
7035                    (
7036                        header::CONTENT_DISPOSITION,
7037                        format!("attachment; filename=\"{filename}\""),
7038                    ),
7039                ],
7040                bytes,
7041            )
7042                .into_response()
7043        },
7044    )
7045}
7046
7047fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7048    let Some(path) = csv_path else {
7049        let html = render_error_artifact_html(
7050            "CSV report was not generated for this run, or was not recorded in \
7051             the scan registry."
7052                .to_string(),
7053            Some(format!("/runs/html/{run_id}")),
7054            Some("View HTML Report".to_string()),
7055            Some(run_id.to_string()),
7056            Some(404),
7057            csp_nonce,
7058        );
7059        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7060    };
7061    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7062}
7063
7064fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7065    let Some(path) = xlsx_path else {
7066        let html = render_error_artifact_html(
7067            "Excel report was not generated for this run, or was not recorded in \
7068             the scan registry."
7069                .to_string(),
7070            Some(format!("/runs/html/{run_id}")),
7071            Some("View HTML Report".to_string()),
7072            Some(run_id.to_string()),
7073            Some(404),
7074            csp_nonce,
7075        );
7076        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7077    };
7078    serve_binary_download(
7079        &path,
7080        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7081        "report.xlsx",
7082    )
7083}
7084
7085fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7086    let path = artifact_set
7087        .scan_config_path
7088        .as_deref()
7089        .map(std::path::Path::to_path_buf)
7090        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7091        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7092    fs::read(&path).map_or_else(
7093        |_| StatusCode::NOT_FOUND.into_response(),
7094        |bytes| {
7095            (
7096                [
7097                    (
7098                        header::CONTENT_TYPE,
7099                        "application/json; charset=utf-8".to_string(),
7100                    ),
7101                    (
7102                        header::CONTENT_DISPOSITION,
7103                        "attachment; filename=\"scan-config.json\"".to_string(),
7104                    ),
7105                ],
7106                bytes,
7107            )
7108                .into_response()
7109        },
7110    )
7111}
7112
7113/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7114/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7115/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7116/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7117async fn serve_submodule_pdf_arm(
7118    artifact: &str,
7119    artifact_set: RunArtifacts,
7120    wants_download: bool,
7121    run_id: &str,
7122    csp_nonce: &str,
7123) -> Response {
7124    // "sub_benchmark_pdf" → base = "sub_benchmark"
7125    let base = artifact.trim_end_matches("_pdf");
7126    let sub_dir = artifact_set.output_dir.join("submodules");
7127    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7128
7129    if !pdf_path.exists() {
7130        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7131        let derived_safe = base.trim_start_matches("sub_");
7132        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7133            let parent_run = read_json(jp).ok()?;
7134            let sub = parent_run
7135                .submodule_summaries
7136                .iter()
7137                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7138                .clone();
7139            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7140            Some((parent_run, sub, parent_path))
7141        });
7142
7143        if let Some((parent_run, sub, parent_path)) = rebuilt {
7144            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7145            let pp = pdf_path.clone();
7146            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7147        }
7148    }
7149
7150    if !pdf_path.exists() {
7151        let html = render_error_artifact_html(
7152            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7153             enabled."
7154                .to_string(),
7155            Some("/view-reports".to_string()),
7156            Some("View Reports".to_string()),
7157            Some(run_id.to_string()),
7158            Some(404),
7159            csp_nonce,
7160        );
7161        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7162    }
7163
7164    serve_pdf_artifact(
7165        &pdf_path,
7166        &artifact_set.report_title,
7167        run_id,
7168        wants_download,
7169        csp_nonce,
7170    )
7171}
7172
7173fn serve_submodule_arm(
7174    artifact: &str,
7175    artifact_set: &RunArtifacts,
7176    wants_download: bool,
7177    csp_nonce: &str,
7178    run_id: &str,
7179    server_mode: bool,
7180) -> Response {
7181    if artifact.len() > 128
7182        || !artifact
7183            .chars()
7184            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7185    {
7186        return StatusCode::BAD_REQUEST.into_response();
7187    }
7188    let filename = format!("{artifact}.html");
7189    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7190    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7191    let path = if new_layout.exists() {
7192        new_layout
7193    } else {
7194        artifact_set.output_dir.join(&filename)
7195    };
7196    if !path.exists() {
7197        let html = render_error_artifact_html(
7198            format!(
7199                "Sub-report '{artifact}' was not found in the run directory.\n\
7200                 Re-run the analysis with 'Detect and separate git submodules' \
7201                 and HTML output enabled."
7202            ),
7203            Some("/view-reports".to_string()),
7204            Some("View Reports".to_string()),
7205            Some(run_id.to_string()),
7206            Some(404),
7207            csp_nonce,
7208        );
7209        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7210    }
7211    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7212}
7213
7214async fn serve_pdf_arm(
7215    state: &AppState,
7216    artifact_set: RunArtifacts,
7217    wants_download: bool,
7218    run_id: &str,
7219    csp_nonce: &str,
7220) -> Response {
7221    let report_title = artifact_set.report_title.clone();
7222    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7223    let stale_html_name = artifact_set
7224        .html_path
7225        .as_deref()
7226        .and_then(|p| p.file_name())
7227        .map(|n| n.to_string_lossy().into_owned());
7228    let path = match resolve_or_queue_pdf(
7229        state,
7230        artifact_set.pdf_path,
7231        artifact_set.json_path.clone(),
7232        artifact_set.output_dir.clone(),
7233        run_id,
7234        &report_title,
7235        csp_nonce,
7236    )
7237    .await
7238    {
7239        Ok(p) => p,
7240        Err(r) => return r,
7241    };
7242    if !path.exists() {
7243        // Distinguish a stale registry path (folder moved) from an in-progress
7244        // background generation. Only show the locate page when the PDF was
7245        // already recorded in the registry but the file is now missing.
7246        if had_pdf_in_registry {
7247            if let Some(expected_filename) = stale_html_name {
7248                let html = LocateFileTemplate {
7249                    run_id: run_id.to_string(),
7250                    artifact_type: "pdf".to_string(),
7251                    expected_filename,
7252                    server_mode: state.server_mode,
7253                    csp_nonce: csp_nonce.to_string(),
7254                    version: env!("CARGO_PKG_VERSION"),
7255                }
7256                .render()
7257                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7258                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7259            }
7260        }
7261        return pdf_generating_response(run_id, csp_nonce);
7262    }
7263    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7264}
7265
7266async fn artifact_handler(
7267    State(state): State<AppState>,
7268    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7269    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7270    Query(query): Query<ArtifactQuery>,
7271) -> Response {
7272    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7273        Ok(a) => a,
7274        Err(r) => return r,
7275    };
7276
7277    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7278
7279    match artifact.as_str() {
7280        "html" => {
7281            let Some(path) = artifact_set.html_path else {
7282                return StatusCode::NOT_FOUND.into_response();
7283            };
7284            serve_html_artifact(
7285                &path,
7286                wants_download,
7287                &csp_nonce,
7288                &run_id,
7289                state.server_mode,
7290            )
7291        }
7292        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7293        "json" => {
7294            let Some(path) = artifact_set.json_path else {
7295                let html = render_error_artifact_html(
7296                    "JSON result was not generated for this run, or was not recorded in \
7297                     the scan registry. Re-run the analysis with JSON output enabled."
7298                        .to_string(),
7299                    Some("/view-reports".to_string()),
7300                    Some("View Reports".to_string()),
7301                    Some(run_id.clone()),
7302                    Some(404),
7303                    &csp_nonce,
7304                );
7305                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7306            };
7307            serve_json_artifact(&path, wants_download, &csp_nonce)
7308        }
7309        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7310        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7311        "scan-config" => serve_scan_config_arm(&artifact_set),
7312        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7313            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7314                .await
7315        }
7316        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7317            &artifact,
7318            &artifact_set,
7319            wants_download,
7320            &csp_nonce,
7321            &run_id,
7322            state.server_mode,
7323        ),
7324        _ => StatusCode::NOT_FOUND.into_response(),
7325    }
7326}
7327
7328// ── History ───────────────────────────────────────────────────────────────────
7329
7330struct SubmoduleLinkRow {
7331    name: String,
7332    url: String,
7333}
7334
7335struct HistoryEntryRow {
7336    run_id: String,
7337    run_id_short: String,
7338    timestamp: String,
7339    timestamp_utc_ms: i64,
7340    project_label: String,
7341    project_path: String,
7342    files_analyzed: u64,
7343    files_skipped: u64,
7344    code_lines: u64,
7345    comment_lines: u64,
7346    blank_lines: u64,
7347    total_physical_lines: u64,
7348    functions: u64,
7349    classes: u64,
7350    variables: u64,
7351    imports: u64,
7352    test_count: u64,
7353    git_branch: String,
7354    git_commit: String,
7355    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7356    git_commit_long: String,
7357    has_html: bool,
7358    has_json: bool,
7359    has_pdf: bool,
7360    submodule_links: Vec<SubmoduleLinkRow>,
7361    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7362    submodule_names_csv: String,
7363}
7364
7365/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7366fn nth_weekday_of_month(
7367    year: i32,
7368    month: u32,
7369    weekday: chrono::Weekday,
7370    n: u32,
7371) -> chrono::NaiveDate {
7372    use chrono::Datelike;
7373    let mut count = 0u32;
7374    let mut day = 1u32;
7375    loop {
7376        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7377        if d.weekday() == weekday {
7378            count += 1;
7379            if count == n {
7380                return d;
7381            }
7382        }
7383        day += 1;
7384    }
7385}
7386
7387/// Returns true if `dt` falls within US Pacific Daylight Time.
7388/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7389/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7390fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7391    use chrono::{Datelike, TimeZone};
7392    let year = dt.year();
7393    let dst_start = chrono::Utc.from_utc_datetime(
7394        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7395            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7396    );
7397    let dst_end = chrono::Utc.from_utc_datetime(
7398        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7399            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7400    );
7401    dt >= dst_start && dt < dst_end
7402}
7403
7404fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7405    if is_pacific_dst(dt) {
7406        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7407            .format("%Y-%m-%d %H:%M PDT")
7408            .to_string()
7409    } else {
7410        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7411            .format("%Y-%m-%d %H:%M PST")
7412            .to_string()
7413    }
7414}
7415
7416/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7417fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7418    let (offset, tz) = if is_pacific_dst(dt) {
7419        (
7420            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7421            "PDT",
7422        )
7423    } else {
7424        (
7425            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7426            "PST",
7427        )
7428    };
7429    format!(
7430        "{} {tz}",
7431        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7432    )
7433}
7434
7435fn fmt_git_date(iso: &str) -> Option<String> {
7436    chrono::DateTime::parse_from_rfc3339(iso)
7437        .ok()
7438        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7439}
7440
7441/// Recover the full-length commit SHA for a registry entry whose stored record
7442/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7443///
7444/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7445/// is serialized after the per-file records, near the end of the file. We read a
7446/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7447/// known short SHA — this disambiguates the super-repo commit from any submodule
7448/// commits that also appear. Returns `None` if the file is unreadable or no match.
7449fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7450    use std::io::{Read, Seek, SeekFrom};
7451    if short.is_empty() {
7452        return None;
7453    }
7454    let len = std::fs::metadata(path).ok()?.len();
7455    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7456    let start = len.saturating_sub(TAIL);
7457    let mut file = std::fs::File::open(path).ok()?;
7458    file.seek(SeekFrom::Start(start)).ok()?;
7459    let mut buf = Vec::new();
7460    file.read_to_end(&mut buf).ok()?;
7461    let text = String::from_utf8_lossy(&buf);
7462    let short_lower = short.to_ascii_lowercase();
7463    let key = "\"git_commit_long\"";
7464    let mut found: Option<String> = None;
7465    let mut cursor = 0usize;
7466    while let Some(idx) = text[cursor..].find(key) {
7467        let after_key = cursor + idx + key.len();
7468        cursor = after_key;
7469        let rest = &text[after_key..];
7470        let Some(colon) = rest.find(':') else { break };
7471        let value_region = rest[colon + 1..].trim_start();
7472        // Skip `null` (or any non-string) values without consuming the next field.
7473        if let Some(open) = value_region.strip_prefix('"') {
7474            if let Some(close) = open.find('"') {
7475                let val = &open[..close];
7476                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7477                    found = Some(val.to_string());
7478                }
7479            }
7480        }
7481    }
7482    found
7483}
7484
7485fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7486    reg.entries
7487        .iter()
7488        .map(|e| {
7489            let submodule_links = {
7490                let mut links: Vec<SubmoduleLinkRow> = vec![];
7491                let sub_dir = e
7492                    .html_path
7493                    .as_ref()
7494                    .and_then(|p| p.parent())
7495                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7496                if let Some(dir) = sub_dir {
7497                    if let Ok(rd) = std::fs::read_dir(dir) {
7498                        for entry_res in rd.flatten() {
7499                            let fname = entry_res.file_name();
7500                            let fname_str = fname.to_string_lossy();
7501                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7502                                let stem = &fname_str[..fname_str.len() - 5];
7503                                let display = stem[4..].replace('-', " ");
7504                                links.push(SubmoduleLinkRow {
7505                                    name: display,
7506                                    url: format!("/runs/{stem}/{}", e.run_id),
7507                                });
7508                            }
7509                        }
7510                    }
7511                }
7512                links.sort_by(|a, b| a.name.cmp(&b.name));
7513                links
7514            };
7515            let submodule_names_csv = submodule_links
7516                .iter()
7517                .map(|l| l.name.as_str())
7518                .collect::<Vec<_>>()
7519                .join(",");
7520            HistoryEntryRow {
7521                run_id: e.run_id.clone(),
7522                run_id_short: e
7523                    .run_id
7524                    .split('-')
7525                    .next_back()
7526                    .unwrap_or(&e.run_id)
7527                    .chars()
7528                    .take(7)
7529                    .collect(),
7530                timestamp: fmt_la_time(e.timestamp_utc),
7531                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7532                project_label: e.project_label.clone(),
7533                project_path: e
7534                    .input_roots
7535                    .first()
7536                    .map(|s| sanitize_path_str(s))
7537                    .unwrap_or_default(),
7538                files_analyzed: e.summary.files_analyzed,
7539                files_skipped: e.summary.files_skipped,
7540                code_lines: e.summary.code_lines,
7541                comment_lines: e.summary.comment_lines,
7542                blank_lines: e.summary.blank_lines,
7543                total_physical_lines: e.summary.total_physical_lines,
7544                functions: e.summary.functions,
7545                classes: e.summary.classes,
7546                variables: e.summary.variables,
7547                imports: e.summary.imports,
7548                test_count: e.summary.test_count,
7549                git_branch: e.git_branch.clone().unwrap_or_default(),
7550                git_commit: e.git_commit.clone().unwrap_or_default(),
7551                git_commit_long: {
7552                    let short = e.git_commit.clone().unwrap_or_default();
7553                    e.git_commit_long
7554                        .clone()
7555                        .filter(|s| !s.is_empty())
7556                        .or_else(|| {
7557                            e.json_path
7558                                .as_ref()
7559                                .and_then(|p| extract_long_commit_from_json(p, &short))
7560                        })
7561                        .unwrap_or(short)
7562                },
7563                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7564                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7565                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7566                submodule_links,
7567                submodule_names_csv,
7568            }
7569        })
7570        .collect()
7571}
7572
7573#[derive(Deserialize, Default)]
7574struct HistoryQuery {
7575    linked: Option<String>,
7576    error: Option<String>,
7577}
7578
7579async fn history_handler(
7580    State(state): State<AppState>,
7581    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7582    Query(query): Query<HistoryQuery>,
7583) -> impl IntoResponse {
7584    // Auto-scan all watched directories before rendering so the list stays fresh.
7585    auto_scan_watched_dirs(&state).await;
7586    let watched_dirs: Vec<String> = {
7587        let wd = state.watched_dirs.lock().await;
7588        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7589    };
7590    let mut entries = {
7591        let reg = state.registry.lock().await;
7592        make_history_rows(&reg)
7593    };
7594    entries.retain(|e| e.has_html);
7595    let total_scans = entries.len();
7596    let linked_count = query
7597        .linked
7598        .as_deref()
7599        .and_then(|s| s.parse::<usize>().ok())
7600        .unwrap_or(0);
7601    let browse_error = query.error.filter(|s| !s.is_empty());
7602    let template = HistoryTemplate {
7603        version: env!("CARGO_PKG_VERSION"),
7604        entries,
7605        total_scans,
7606        linked_count,
7607        browse_error,
7608        watched_dirs,
7609        csp_nonce,
7610        server_mode: state.server_mode,
7611    };
7612    Html(
7613        template
7614            .render()
7615            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7616    )
7617    .into_response()
7618}
7619
7620async fn compare_select_handler(
7621    State(state): State<AppState>,
7622    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7623) -> impl IntoResponse {
7624    auto_scan_watched_dirs(&state).await;
7625    let watched_dirs: Vec<String> = {
7626        let wd = state.watched_dirs.lock().await;
7627        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7628    };
7629    let mut entries = {
7630        let reg = state.registry.lock().await;
7631        make_history_rows(&reg)
7632    };
7633    entries.retain(|e| e.has_json);
7634    let total_scans = entries.len();
7635    let template = CompareSelectTemplate {
7636        version: env!("CARGO_PKG_VERSION"),
7637        entries,
7638        total_scans,
7639        watched_dirs,
7640        csp_nonce,
7641        server_mode: state.server_mode,
7642    };
7643    Html(
7644        template
7645            .render()
7646            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7647    )
7648    .into_response()
7649}
7650
7651// ── Compare ───────────────────────────────────────────────────────────────────
7652
7653#[derive(Deserialize, Default)]
7654struct CompareQuery {
7655    a: Option<String>,
7656    b: Option<String>,
7657    /// Optional submodule name to scope the comparison to one submodule.
7658    sub: Option<String>,
7659    /// "super" to exclude all submodule files and show only the super-repo.
7660    scope: Option<String>,
7661}
7662
7663struct CompareFileDeltaRow {
7664    relative_path: String,
7665    language: String,
7666    status: String,
7667    baseline_code: i64,
7668    current_code: i64,
7669    baseline_code_display: String,
7670    current_code_display: String,
7671    code_delta_str: String,
7672    code_delta_class: String,
7673    comment_delta_str: String,
7674    comment_delta_class: String,
7675    total_delta_str: String,
7676    total_delta_class: String,
7677}
7678
7679/// Recompute `summary_totals` from the current `per_file_records` slice.
7680/// Used when `per_file_records` has been narrowed to a submodule subset.
7681fn recompute_summary_from_records(run: &mut AnalysisRun) {
7682    let mut totals = SummaryTotals::default();
7683    for r in &run.per_file_records {
7684        if r.language.is_some() {
7685            totals.files_analyzed += 1;
7686        }
7687        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7688        totals.code_lines += r.effective_counts.code_lines;
7689        totals.comment_lines += r.effective_counts.comment_lines;
7690        totals.blank_lines += r.effective_counts.blank_lines;
7691        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7692        totals.functions += r.raw_line_categories.functions;
7693        totals.classes += r.raw_line_categories.classes;
7694        totals.variables += r.raw_line_categories.variables;
7695        totals.imports += r.raw_line_categories.imports;
7696        totals.test_count += r.raw_line_categories.test_count;
7697        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7698        totals.test_suite_count += r.raw_line_categories.test_suite_count;
7699        if let Some(cov) = &r.coverage {
7700            totals.coverage_lines_found += u64::from(cov.lines_found);
7701            totals.coverage_lines_hit += u64::from(cov.lines_hit);
7702            totals.coverage_functions_found += u64::from(cov.functions_found);
7703            totals.coverage_functions_hit += u64::from(cov.functions_hit);
7704            totals.coverage_branches_found += u64::from(cov.branches_found);
7705            totals.coverage_branches_hit += u64::from(cov.branches_hit);
7706        }
7707    }
7708    totals.files_considered = totals.files_analyzed;
7709    run.summary_totals = totals;
7710}
7711
7712fn fmt_delta(n: i64) -> String {
7713    if n > 0 {
7714        format!("+{n}")
7715    } else {
7716        format!("{n}")
7717    }
7718}
7719
7720fn delta_class(n: i64) -> &'static str {
7721    use std::cmp::Ordering;
7722    match n.cmp(&0) {
7723        Ordering::Greater => "pos",
7724        Ordering::Less => "neg",
7725        Ordering::Equal => "zero",
7726    }
7727}
7728
7729// ratio/percentage display, precision loss acceptable
7730#[allow(clippy::cast_precision_loss)]
7731fn fmt_pct(delta: i64, baseline: u64) -> String {
7732    if baseline == 0 {
7733        return "—".to_string();
7734    }
7735    #[allow(clippy::cast_precision_loss)]
7736    let pct = (delta as f64 / baseline as f64) * 100.0;
7737    if pct > 0.049 {
7738        format!("+{pct:.1}%")
7739    } else if pct < -0.049 {
7740        format!("{pct:.1}%")
7741    } else {
7742        "±0%".to_string()
7743    }
7744}
7745
7746/// Returns (`display_string`, `css_class`) for a numeric change column cell.
7747fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7748    prev.map_or_else(
7749        || ("—".to_string(), "na"),
7750        |p| {
7751            #[allow(clippy::cast_possible_wrap)]
7752            let d = curr as i64 - p as i64;
7753            (fmt_delta(d), delta_class(d))
7754        },
7755    )
7756}
7757
7758#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
7759fn load_scan_for_compare(
7760    json_path: &std::path::Path,
7761    scan_label: &str,
7762    run_id: &str,
7763    server_mode: bool,
7764    compare_url: &str,
7765    csp_nonce: &str,
7766) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7767    match read_json(json_path) {
7768        Ok(r) => Ok(r),
7769        Err(e) => {
7770            if server_mode {
7771                let html = ErrorTemplate {
7772                    message: format!(
7773                        "Could not load {scan_label} scan data. The scan output folder may have \
7774                         been moved, renamed, or deleted. Re-running the analysis will create \
7775                         fresh comparison data."
7776                    ),
7777                    last_report_url: Some("/compare-scans".to_string()),
7778                    last_report_label: Some("Compare Scans".to_string()),
7779                    run_id: Some(run_id.to_owned()),
7780                    error_code: Some(404),
7781                    csp_nonce: csp_nonce.to_owned(),
7782                    version: env!("CARGO_PKG_VERSION"),
7783                }
7784                .render()
7785                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7786                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7787            }
7788            let msg = format!(
7789                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7790                json_path.display()
7791            );
7792            let folder_hint = output_folder_hint(json_path);
7793            Err(missing_scan_relocate_response(
7794                &msg,
7795                run_id,
7796                &folder_hint,
7797                compare_url,
7798                false,
7799                csp_nonce,
7800            ))
7801        }
7802    }
7803}
7804
7805struct ChurnStats {
7806    new_scope: bool,
7807    scope_flag: bool,
7808    churn_rate_str: String,
7809    churn_rate_class: String,
7810}
7811
7812fn compute_churn_stats(
7813    baseline_code: u64,
7814    current_code: u64,
7815    lines_added: i64,
7816    lines_removed: i64,
7817) -> ChurnStats {
7818    let new_scope = baseline_code == 0 && current_code > 0;
7819    #[allow(clippy::cast_precision_loss)]
7820    let churn_pct = if baseline_code > 0 {
7821        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7822    } else {
7823        0.0
7824    };
7825    #[allow(clippy::cast_precision_loss)]
7826    let scope_flag =
7827        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7828    let churn_rate_str = if new_scope {
7829        "New".to_string()
7830    } else if baseline_code > 0 {
7831        format!("{churn_pct:.1}%")
7832    } else {
7833        "—".to_string()
7834    };
7835    let churn_rate_class = if new_scope || churn_pct > 20.0 {
7836        "high".to_string()
7837    } else if churn_pct > 5.0 {
7838        "med".to_string()
7839    } else {
7840        "low".to_string()
7841    };
7842    ChurnStats {
7843        new_scope,
7844        scope_flag,
7845        churn_rate_str,
7846        churn_rate_class,
7847    }
7848}
7849
7850/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7851/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7852/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7853fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7854    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7855    if !has_data {
7856        return String::new();
7857    }
7858    let base_str = s
7859        .baseline_coverage_line_pct
7860        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7861    let curr_str = s
7862        .current_coverage_line_pct
7863        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7864    let (delta_str, cls) = match s.coverage_line_pct_delta {
7865        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7866        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7867        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7868        None => ("\u{2014}".into(), "zero"),
7869    };
7870    format!(
7871        r#"<div class="delta-card">
7872          <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>
7873          <div class="delta-card-label">Line coverage</div>
7874          <div class="delta-card-from">Before: {base_str}</div>
7875          <div class="delta-card-to">{curr_str}</div>
7876          <span class="delta-card-change {cls}">{delta_str}</span>
7877        </div>"#
7878    )
7879}
7880
7881/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
7882#[allow(clippy::ref_option)]
7883fn narrow_run_pair_by_scope(
7884    mut baseline: AnalysisRun,
7885    mut current: AnalysisRun,
7886    active_sub: &Option<String>,
7887    super_scope: bool,
7888) -> (AnalysisRun, AnalysisRun) {
7889    if let Some(ref sub_name) = active_sub {
7890        baseline
7891            .per_file_records
7892            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7893        current
7894            .per_file_records
7895            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7896        recompute_summary_from_records(&mut baseline);
7897        recompute_summary_from_records(&mut current);
7898    } else if super_scope {
7899        baseline.per_file_records.retain(|f| f.submodule.is_none());
7900        current.per_file_records.retain(|f| f.submodule.is_none());
7901        recompute_summary_from_records(&mut baseline);
7902        recompute_summary_from_records(&mut current);
7903    }
7904    (baseline, current)
7905}
7906
7907/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
7908#[allow(clippy::ref_option)]
7909fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7910    if let Some(ref sub_name) = active_sub {
7911        for run in runs.iter_mut() {
7912            run.per_file_records
7913                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7914            recompute_summary_from_records(run);
7915        }
7916    } else if super_scope {
7917        for run in runs.iter_mut() {
7918            run.per_file_records.retain(|f| f.submodule.is_none());
7919            recompute_summary_from_records(run);
7920        }
7921    }
7922}
7923
7924#[allow(clippy::too_many_lines)]
7925async fn compare_handler(
7926    State(state): State<AppState>,
7927    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7928    Query(query): Query<CompareQuery>,
7929) -> impl IntoResponse {
7930    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
7931    // redirect to the history page where the user can select two runs.
7932    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7933        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7934        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7935    };
7936
7937    let (maybe_a, maybe_b) = {
7938        let reg = state.registry.lock().await;
7939        (
7940            reg.find_by_run_id(&run_id_a).cloned(),
7941            reg.find_by_run_id(&run_id_b).cloned(),
7942        )
7943    };
7944
7945    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7946        let html = ErrorTemplate {
7947            message: "One or both run IDs were not found in scan history. \
7948                      The runs may have been deleted or the registry may have been reset."
7949                .to_string(),
7950            last_report_url: Some("/compare-scans".to_string()),
7951            last_report_label: Some("Compare Scans".to_string()),
7952            run_id: None,
7953            error_code: None,
7954            csp_nonce: csp_nonce.clone(),
7955            version: env!("CARGO_PKG_VERSION"),
7956        }
7957        .render()
7958        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7959        return Html(html).into_response();
7960    };
7961
7962    // Ensure older scan is always the baseline.
7963    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7964        (entry_a, entry_b)
7965    } else {
7966        (entry_b, entry_a)
7967    };
7968
7969    // If query params were in the wrong order, redirect to canonical URL so the
7970    // browser always shows the same URL for the same two scans regardless of how
7971    // the user arrived here (Full diff button vs. Compare Scans selection).
7972    if baseline_entry.run_id != run_id_a {
7973        let canonical = format!(
7974            "/compare?a={}&b={}",
7975            baseline_entry.run_id, current_entry.run_id
7976        );
7977        return axum::response::Redirect::to(&canonical).into_response();
7978    }
7979
7980    let (Some(base_json), Some(curr_json)) = (
7981        baseline_entry.json_path.as_ref(),
7982        current_entry.json_path.as_ref(),
7983    ) else {
7984        let html = ErrorTemplate {
7985            message: "Full comparison requires JSON scan data, which was not saved for one or \
7986                      both of these runs. JSON is now always saved for new scans — re-run the \
7987                      affected projects to enable comparisons."
7988                .to_string(),
7989            last_report_url: Some("/compare-scans".to_string()),
7990            last_report_label: Some("Compare Scans".to_string()),
7991            run_id: None,
7992            error_code: None,
7993            csp_nonce: csp_nonce.clone(),
7994            version: env!("CARGO_PKG_VERSION"),
7995        }
7996        .render()
7997        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7998        return Html(html).into_response();
7999    };
8000
8001    let compare_url = format!(
8002        "/compare?a={}&b={}",
8003        baseline_entry.run_id, current_entry.run_id
8004    );
8005
8006    let baseline_run = match load_scan_for_compare(
8007        base_json,
8008        "baseline",
8009        &baseline_entry.run_id,
8010        state.server_mode,
8011        &compare_url,
8012        &csp_nonce,
8013    ) {
8014        Ok(r) => r,
8015        Err(resp) => return resp,
8016    };
8017    let current_run = match load_scan_for_compare(
8018        curr_json,
8019        "current",
8020        &current_entry.run_id,
8021        state.server_mode,
8022        &compare_url,
8023        &csp_nonce,
8024    ) {
8025        Ok(r) => r,
8026        Err(resp) => return resp,
8027    };
8028
8029    let active_submodule = query.sub.clone();
8030    let super_scope_active = query.scope.as_deref() == Some("super");
8031
8032    let submodule_options = baseline_run
8033        .submodule_summaries
8034        .iter()
8035        .chain(current_run.submodule_summaries.iter())
8036        .map(|s| s.name.clone())
8037        .collect::<std::collections::BTreeSet<_>>()
8038        .into_iter()
8039        .collect::<Vec<_>>();
8040    let has_any_submodule_data = !submodule_options.is_empty();
8041
8042    // Narrow per_file_records when a scope is active, then recompute totals.
8043    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8044        baseline_run,
8045        current_run,
8046        &active_submodule,
8047        super_scope_active,
8048    );
8049
8050    let comparison = compute_delta(&effective_baseline, &effective_current);
8051
8052    let file_rows: Vec<CompareFileDeltaRow> = comparison
8053        .file_deltas
8054        .iter()
8055        .map(|d| CompareFileDeltaRow {
8056            relative_path: d.relative_path.clone(),
8057            language: d.language.clone().unwrap_or_else(|| "—".into()),
8058            status: match d.status {
8059                FileChangeStatus::Added => "added".into(),
8060                FileChangeStatus::Removed => "removed".into(),
8061                FileChangeStatus::Modified => "modified".into(),
8062                FileChangeStatus::Unchanged => "unchanged".into(),
8063            },
8064            baseline_code: d.baseline_code,
8065            current_code: d.current_code,
8066            baseline_code_display: if d.status == FileChangeStatus::Added {
8067                "—".into()
8068            } else {
8069                d.baseline_code.to_string()
8070            },
8071            current_code_display: if d.status == FileChangeStatus::Removed {
8072                "—".into()
8073            } else {
8074                d.current_code.to_string()
8075            },
8076            code_delta_str: fmt_delta(d.code_delta),
8077            code_delta_class: delta_class(d.code_delta).into(),
8078            comment_delta_str: fmt_delta(d.comment_delta),
8079            comment_delta_class: delta_class(d.comment_delta).into(),
8080            total_delta_str: fmt_delta(d.total_delta),
8081            total_delta_class: delta_class(d.total_delta).into(),
8082        })
8083        .collect();
8084
8085    let project_path = baseline_entry
8086        .input_roots
8087        .first()
8088        .map(|s| sanitize_path_str(s))
8089        .unwrap_or_default();
8090    let lines_added = sum_added_code_lines(&comparison);
8091    let lines_removed = sum_removed_code_lines(&comparison);
8092    let churn = compute_churn_stats(
8093        comparison.summary.baseline_code,
8094        comparison.summary.current_code,
8095        lines_added,
8096        lines_removed,
8097    );
8098    let s = &comparison.summary;
8099    let template = CompareTemplate {
8100        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8101        version: env!("CARGO_PKG_VERSION"),
8102        project_label: baseline_entry.project_label.clone(),
8103        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8104        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8105        baseline_run_id: baseline_entry.run_id.clone(),
8106        current_run_id: current_entry.run_id.clone(),
8107        baseline_run_id_short: baseline_entry
8108            .run_id
8109            .split('-')
8110            .next_back()
8111            .unwrap_or(&baseline_entry.run_id)
8112            .chars()
8113            .take(7)
8114            .collect(),
8115        current_run_id_short: current_entry
8116            .run_id
8117            .split('-')
8118            .next_back()
8119            .unwrap_or(&current_entry.run_id)
8120            .chars()
8121            .take(7)
8122            .collect(),
8123        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8124        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8125        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8126        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8127        project_path: project_path.clone(),
8128        baseline_code: s.baseline_code,
8129        current_code: s.current_code,
8130        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8131        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8132        baseline_files: s.baseline_files,
8133        current_files: s.current_files,
8134        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8135        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8136        baseline_comments: s.baseline_comments,
8137        current_comments: s.current_comments,
8138        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8139        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8140        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8141        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8142        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8143        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8144        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8145        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8146        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8147        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8148        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8149        code_lines_added: lines_added,
8150        code_lines_removed: lines_removed,
8151        new_scope: churn.new_scope,
8152        churn_rate_str: churn.churn_rate_str,
8153        churn_rate_class: churn.churn_rate_class,
8154        scope_flag: churn.scope_flag,
8155        files_added: comparison.files_added,
8156        files_removed: comparison.files_removed,
8157        files_modified: comparison.files_modified,
8158        files_unchanged: comparison.files_unchanged,
8159        file_rows,
8160        baseline_git_author: baseline_entry.git_author.clone(),
8161        current_git_author: current_entry.git_author.clone(),
8162        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8163        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8164        baseline_git_tags: baseline_entry.git_tags.clone(),
8165        current_git_tags: current_entry.git_tags.clone(),
8166        baseline_git_commit_date: baseline_entry
8167            .git_commit_date
8168            .as_deref()
8169            .and_then(fmt_git_date),
8170        current_git_commit_date: current_entry
8171            .git_commit_date
8172            .as_deref()
8173            .and_then(fmt_git_date),
8174        project_name: project_path
8175            .rsplit(['/', '\\'])
8176            .find(|s| !s.is_empty())
8177            .unwrap_or(&project_path)
8178            .to_string(),
8179        submodule_options,
8180        has_any_submodule_data,
8181        active_submodule,
8182        super_scope_active,
8183        toast_assets: sloc_toast_assets(&csp_nonce),
8184        csp_nonce,
8185        coverage_delta_card: build_coverage_delta_card(s),
8186        baseline_test_count: effective_baseline.summary_totals.test_count,
8187        current_test_count: effective_current.summary_totals.test_count,
8188        baseline_coverage_pct: s.baseline_coverage_line_pct,
8189        current_coverage_pct: s.current_coverage_line_pct,
8190    };
8191
8192    Html(
8193        template
8194            .render()
8195            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8196    )
8197    .into_response()
8198}
8199
8200// ── Badge endpoint ────────────────────────────────────────────────────────────
8201// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8202// pages, Jira descriptions, etc.
8203//
8204// GET /badge/<metric>?label=<override>&color=<hex>
8205// Metrics: code-lines  files  comment-lines  blank-lines
8206
8207fn format_number(n: u64) -> String {
8208    let s = n.to_string();
8209    let mut out = String::with_capacity(s.len() + s.len() / 3);
8210    let len = s.len();
8211    for (i, c) in s.chars().enumerate() {
8212        if i > 0 && (len - i).is_multiple_of(3) {
8213            out.push(',');
8214        }
8215        out.push(c);
8216    }
8217    out
8218}
8219
8220const fn badge_char_width(c: char) -> f64 {
8221    match c {
8222        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8223        'm' | 'w' => 9.0,
8224        ' ' => 4.0,
8225        _ => 6.5,
8226    }
8227}
8228
8229#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8230fn badge_text_px(text: &str) -> u32 {
8231    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8232}
8233
8234fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8235    let lw = badge_text_px(label) + 20;
8236    let rw = badge_text_px(value) + 20;
8237    let total = lw + rw;
8238    let lx = lw / 2;
8239    let rx = lw + rw / 2;
8240    let le = escape_html(label);
8241    let ve = escape_html(value);
8242    let ce = escape_html(color);
8243    format!(
8244        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8245  <rect width="{total}" height="20" fill="#555"/>
8246  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8247  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8248    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8249    <text x="{lx}" y="13">{le}</text>
8250    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8251    <text x="{rx}" y="13">{ve}</text>
8252  </g>
8253</svg>"##
8254    )
8255}
8256
8257#[derive(Deserialize)]
8258struct BadgeQuery {
8259    label: Option<String>,
8260    color: Option<String>,
8261}
8262
8263async fn badge_handler(
8264    State(state): State<AppState>,
8265    AxumPath(metric): AxumPath<String>,
8266    Query(query): Query<BadgeQuery>,
8267) -> Response {
8268    let entry = {
8269        let reg = state.registry.lock().await;
8270        reg.entries.first().cloned()
8271    };
8272
8273    let Some(entry) = entry else {
8274        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8275        return (
8276            [
8277                (header::CONTENT_TYPE, "image/svg+xml"),
8278                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8279            ],
8280            svg,
8281        )
8282            .into_response();
8283    };
8284
8285    let (default_label, value, default_color) = match metric.as_str() {
8286        "code-lines" => (
8287            "code lines",
8288            format_number(entry.summary.code_lines),
8289            "#4a78ee",
8290        ),
8291        "files" => (
8292            "files analyzed",
8293            format_number(entry.summary.files_analyzed),
8294            "#4a9862",
8295        ),
8296        "comment-lines" => (
8297            "comment lines",
8298            format_number(entry.summary.comment_lines),
8299            "#b35428",
8300        ),
8301        "blank-lines" => (
8302            "blank lines",
8303            format_number(entry.summary.blank_lines),
8304            "#7a5db0",
8305        ),
8306        _ => return StatusCode::NOT_FOUND.into_response(),
8307    };
8308
8309    let label = query.label.as_deref().unwrap_or(default_label);
8310    let color = query.color.as_deref().unwrap_or(default_color);
8311    let svg = render_badge_svg(label, &value, color);
8312
8313    (
8314        [
8315            (header::CONTENT_TYPE, "image/svg+xml"),
8316            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8317        ],
8318        svg,
8319    )
8320        .into_response()
8321}
8322
8323// ── Metrics API ───────────────────────────────────────────────────────────────
8324// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8325// Confluence automation, Jira webhooks, etc.
8326//
8327// GET /api/metrics/latest
8328// GET /api/metrics/<run_id>
8329
8330#[derive(Serialize)]
8331struct ApiCoverageBlock {
8332    lines_found: u64,
8333    lines_hit: u64,
8334    line_pct: f64,
8335    functions_found: u64,
8336    functions_hit: u64,
8337    function_pct: f64,
8338    branches_found: u64,
8339    branches_hit: u64,
8340    branch_pct: f64,
8341}
8342
8343#[derive(Serialize)]
8344struct ApiMetricsResponse {
8345    run_id: String,
8346    timestamp: String,
8347    project: String,
8348    summary: ApiSummaryPayload,
8349    languages: Vec<ApiLanguageRow>,
8350    #[serde(skip_serializing_if = "Option::is_none")]
8351    coverage: Option<ApiCoverageBlock>,
8352}
8353
8354#[derive(Serialize)]
8355struct ApiSummaryPayload {
8356    files_analyzed: u64,
8357    files_skipped: u64,
8358    code_lines: u64,
8359    comment_lines: u64,
8360    blank_lines: u64,
8361    total_physical_lines: u64,
8362    functions: u64,
8363    classes: u64,
8364    variables: u64,
8365    imports: u64,
8366}
8367
8368#[derive(Serialize)]
8369struct ApiLanguageRow {
8370    name: String,
8371    files: u64,
8372    code_lines: u64,
8373    comment_lines: u64,
8374    blank_lines: u64,
8375    functions: u64,
8376    classes: u64,
8377    variables: u64,
8378    imports: u64,
8379}
8380
8381async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8382    let entry = {
8383        let reg = state.registry.lock().await;
8384        reg.entries.first().cloned()
8385    };
8386    entry.map_or_else(
8387        || error::not_found("no scans recorded yet"),
8388        |e| build_metrics_response(&e),
8389    )
8390}
8391
8392async fn api_metrics_run_handler(
8393    State(state): State<AppState>,
8394    AxumPath(run_id): AxumPath<String>,
8395) -> Response {
8396    let entry = {
8397        let reg = state.registry.lock().await;
8398        reg.find_by_run_id(&run_id).cloned()
8399    };
8400    entry.map_or_else(
8401        || error::not_found("run not found"),
8402        |e| build_metrics_response(&e),
8403    )
8404}
8405
8406fn build_metrics_response(entry: &RegistryEntry) -> Response {
8407    let languages: Vec<ApiLanguageRow> = entry
8408        .json_path
8409        .as_ref()
8410        .and_then(|p| read_json(p).ok())
8411        .map(|run| {
8412            run.totals_by_language
8413                .iter()
8414                .map(|l| ApiLanguageRow {
8415                    name: l.language.display_name().to_string(),
8416                    files: l.files,
8417                    code_lines: l.code_lines,
8418                    comment_lines: l.comment_lines,
8419                    blank_lines: l.blank_lines,
8420                    functions: l.functions,
8421                    classes: l.classes,
8422                    variables: l.variables,
8423                    imports: l.imports,
8424                })
8425                .collect()
8426        })
8427        .unwrap_or_default();
8428
8429    let s = &entry.summary;
8430    let coverage = if s.coverage_lines_found > 0 {
8431        let pct = |hit: u64, found: u64| -> f64 {
8432            if found == 0 {
8433                0.0
8434            } else {
8435                #[allow(clippy::cast_precision_loss)]
8436                let v = (hit as f64 / found as f64) * 100.0;
8437                (v * 10.0).round() / 10.0
8438            }
8439        };
8440        Some(ApiCoverageBlock {
8441            lines_found: s.coverage_lines_found,
8442            lines_hit: s.coverage_lines_hit,
8443            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8444            functions_found: s.coverage_functions_found,
8445            functions_hit: s.coverage_functions_hit,
8446            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8447            branches_found: s.coverage_branches_found,
8448            branches_hit: s.coverage_branches_hit,
8449            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8450        })
8451    } else {
8452        None
8453    };
8454    Json(ApiMetricsResponse {
8455        run_id: entry.run_id.clone(),
8456        timestamp: entry.timestamp_utc.to_rfc3339(),
8457        project: entry.project_label.clone(),
8458        summary: ApiSummaryPayload {
8459            files_analyzed: s.files_analyzed,
8460            files_skipped: s.files_skipped,
8461            code_lines: s.code_lines,
8462            comment_lines: s.comment_lines,
8463            blank_lines: s.blank_lines,
8464            total_physical_lines: s.total_physical_lines,
8465            functions: s.functions,
8466            classes: s.classes,
8467            variables: s.variables,
8468            imports: s.imports,
8469        },
8470        languages,
8471        coverage,
8472    })
8473    .into_response()
8474}
8475
8476// ── Project history API ───────────────────────────────────────────────────────
8477// Protected. Called by the wizard JS when the project path changes, so the UI
8478// can show a "scanned N times before" badge without a full page reload.
8479//
8480// GET /api/project-history?path=<project_root>
8481
8482#[derive(Deserialize)]
8483struct ProjectHistoryQuery {
8484    path: Option<String>,
8485}
8486
8487#[derive(Serialize)]
8488struct ProjectHistoryResponse {
8489    scan_count: usize,
8490    last_scan_id: Option<String>,
8491    last_scan_timestamp: Option<String>,
8492    last_scan_code_lines: Option<u64>,
8493    last_git_branch: Option<String>,
8494    last_git_commit: Option<String>,
8495}
8496
8497/// Return true if `entry` matches either an exact root path or an upload-staging
8498/// path with the same project name (needed because each upload gets a fresh UUID dir).
8499fn entry_matches_project(
8500    entry: &RegistryEntry,
8501    root_str: &str,
8502    upload_root: &str,
8503    upload_name_suffix: Option<&str>,
8504) -> bool {
8505    if entry.input_roots.iter().any(|r| r == root_str) {
8506        return true;
8507    }
8508    if let Some(suffix) = upload_name_suffix {
8509        return entry
8510            .input_roots
8511            .iter()
8512            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8513    }
8514    false
8515}
8516
8517async fn project_history_handler(
8518    State(state): State<AppState>,
8519    Query(query): Query<ProjectHistoryQuery>,
8520) -> Response {
8521    let path = query.path.unwrap_or_default();
8522    let resolved = resolve_input_path(&path);
8523    let root_str = resolved.to_string_lossy().replace('\\', "/");
8524
8525    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8526    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8527    // previous scans of the same project. Fall back to matching by project name within the
8528    // uploads staging directory so Scan History populates correctly across uploads.
8529    let upload_root = std::env::temp_dir()
8530        .join("oxide-sloc-uploads")
8531        .to_string_lossy()
8532        .replace('\\', "/");
8533    let upload_name_suffix: Option<String> =
8534        if state.server_mode && root_str.starts_with(&upload_root) {
8535            resolved
8536                .file_name()
8537                .and_then(|n| n.to_str())
8538                .map(|name| format!("/{name}"))
8539        } else {
8540            None
8541        };
8542    let suffix_ref = upload_name_suffix.as_deref();
8543
8544    let entries: Vec<_> = {
8545        let reg = state.registry.lock().await;
8546        reg.entries
8547            .iter()
8548            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8549            .cloned()
8550            .collect()
8551    };
8552    let scan_count = entries.len();
8553    let last = entries.first();
8554    let last_scan_id = last.map(|e| e.run_id.clone());
8555    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8556    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8557    let last_git_branch = last.and_then(|e| e.git_branch.clone());
8558    let last_git_commit = last.and_then(|e| e.git_commit.clone());
8559
8560    Json(ProjectHistoryResponse {
8561        scan_count,
8562        last_scan_id,
8563        last_scan_timestamp,
8564        last_scan_code_lines,
8565        last_git_branch,
8566        last_git_commit,
8567    })
8568    .into_response()
8569}
8570
8571// ── Metrics history API ───────────────────────────────────────────────────────
8572// Protected. Returns a JSON array of lightweight scan snapshots for plotting
8573// trend charts.
8574//
8575// GET /api/metrics/history?root=<path>&limit=<n>
8576
8577#[derive(Deserialize)]
8578struct MetricsHistoryQuery {
8579    root: Option<String>,
8580    limit: Option<usize>,
8581    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
8582    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
8583    submodule: Option<String>,
8584}
8585
8586#[derive(Serialize)]
8587struct MetricsSubmoduleLink {
8588    name: String,
8589    url: String,
8590}
8591
8592#[derive(Serialize)]
8593struct MetricsHistoryEntry {
8594    run_id: String,
8595    run_id_short: String,
8596    timestamp: String,
8597    commit: Option<String>,
8598    branch: Option<String>,
8599    tags: Vec<String>,
8600    nearest_tag: Option<String>,
8601    code_lines: u64,
8602    comment_lines: u64,
8603    blank_lines: u64,
8604    physical_lines: u64,
8605    files_analyzed: u64,
8606    files_skipped: u64,
8607    test_count: u64,
8608    project_label: String,
8609    html_url: Option<String>,
8610    has_pdf: bool,
8611    submodule_links: Vec<MetricsSubmoduleLink>,
8612    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
8613    #[serde(skip_serializing_if = "Option::is_none")]
8614    coverage_line_pct: Option<f64>,
8615}
8616
8617fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8618    let mut links: Vec<MetricsSubmoduleLink> = vec![];
8619    let sub_dir = e
8620        .html_path
8621        .as_ref()
8622        .and_then(|p| p.parent())
8623        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8624    let Some(dir) = sub_dir else { return links };
8625    let Ok(rd) = std::fs::read_dir(dir) else {
8626        return links;
8627    };
8628    for entry_res in rd.flatten() {
8629        let fname = entry_res.file_name();
8630        let fname_str = fname.to_string_lossy();
8631        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8632            let stem = &fname_str[..fname_str.len() - 5];
8633            let display = stem[4..].replace('-', " ");
8634            links.push(MetricsSubmoduleLink {
8635                name: display,
8636                url: format!("/runs/{stem}/{}", e.run_id),
8637            });
8638        }
8639    }
8640    links.sort_by(|a, b| a.name.cmp(&b.name));
8641    links
8642}
8643
8644fn apply_submodule_filter(
8645    base: MetricsHistoryEntry,
8646    filter: &str,
8647    e: &sloc_core::history::RegistryEntry,
8648) -> Option<MetricsHistoryEntry> {
8649    let json_path = e.json_path.as_ref()?;
8650    let json_str = std::fs::read_to_string(json_path).ok()?;
8651    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8652    let sub = run
8653        .submodule_summaries
8654        .iter()
8655        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8656    let safe = sanitize_project_label(&sub.name);
8657    let artifact_key = format!("sub_{safe}");
8658    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8659        || base.html_url.clone(),
8660        |run_dir| {
8661            let sub_path = run_dir.join(format!("{artifact_key}.html"));
8662            if sub_path.exists() {
8663                Some(format!("/runs/{artifact_key}/{}", e.run_id))
8664            } else {
8665                base.html_url.clone()
8666            }
8667        },
8668    );
8669
8670    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
8671    // basic SLOC totals, so test_count and coverage must be computed from file records.
8672    let sub_files: Vec<_> = run
8673        .per_file_records
8674        .iter()
8675        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8676        .collect();
8677    let test_count: u64 = sub_files
8678        .iter()
8679        .map(|r| r.raw_line_categories.test_count)
8680        .sum();
8681    #[allow(clippy::cast_precision_loss)]
8682    let coverage_line_pct: Option<f64> = {
8683        let found: u64 = sub_files
8684            .iter()
8685            .filter_map(|r| r.coverage.as_ref())
8686            .map(|c| u64::from(c.lines_found))
8687            .sum();
8688        let hit: u64 = sub_files
8689            .iter()
8690            .filter_map(|r| r.coverage.as_ref())
8691            .map(|c| u64::from(c.lines_hit))
8692            .sum();
8693        if found > 0 {
8694            let pct = (hit as f64 / found as f64) * 100.0;
8695            Some((pct * 10.0).round() / 10.0)
8696        } else {
8697            None
8698        }
8699    };
8700
8701    Some(MetricsHistoryEntry {
8702        code_lines: sub.code_lines,
8703        comment_lines: sub.comment_lines,
8704        blank_lines: sub.blank_lines,
8705        physical_lines: sub.total_physical_lines,
8706        files_analyzed: sub.files_analyzed,
8707        files_skipped: 0,
8708        test_count,
8709        html_url: sub_html_url,
8710        has_pdf: false,
8711        submodule_links: vec![],
8712        coverage_line_pct,
8713        ..base
8714    })
8715}
8716
8717#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
8718async fn api_metrics_history_handler(
8719    State(state): State<AppState>,
8720    Query(query): Query<MetricsHistoryQuery>,
8721) -> Response {
8722    let limit = query.limit.unwrap_or(50).min(500);
8723    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8724
8725    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8726        let reg = state.registry.lock().await;
8727        reg.entries
8728            .iter()
8729            .filter(|e| {
8730                query.root.as_ref().is_none_or(|root| {
8731                    let resolved = resolve_input_path(root);
8732                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8733                    e.input_roots.iter().any(|r| r == &root_str)
8734                })
8735            })
8736            .take(limit)
8737            .cloned()
8738            .collect()
8739    };
8740
8741    let entries: Vec<MetricsHistoryEntry> = candidate_entries
8742        .into_iter()
8743        .filter_map(|e| {
8744            let tags = e
8745                .git_tags
8746                .as_deref()
8747                .map(|s| {
8748                    s.split(',')
8749                        .map(|t| t.trim().to_string())
8750                        .filter(|t| !t.is_empty())
8751                        .collect()
8752                })
8753                .unwrap_or_default();
8754            let html_url = e
8755                .html_path
8756                .as_ref()
8757                .filter(|p| p.exists())
8758                .map(|_| format!("/runs/html/{}", e.run_id));
8759            let nearest_tag = e.git_nearest_tag.clone();
8760            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8761            let run_id_short: String = e
8762                .run_id
8763                .split('-')
8764                .next_back()
8765                .unwrap_or(&e.run_id)
8766                .chars()
8767                .take(7)
8768                .collect();
8769            let submodule_links = build_entry_submodule_links(&e);
8770            #[allow(clippy::cast_precision_loss)]
8771            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8772                let pct = (e.summary.coverage_lines_hit as f64
8773                    / e.summary.coverage_lines_found as f64)
8774                    * 100.0;
8775                Some((pct * 10.0).round() / 10.0)
8776            } else {
8777                None
8778            };
8779            let base = MetricsHistoryEntry {
8780                run_id: e.run_id.clone(),
8781                run_id_short,
8782                timestamp: e.timestamp_utc.to_rfc3339(),
8783                commit: e.git_commit.clone(),
8784                branch: e.git_branch.clone(),
8785                tags,
8786                nearest_tag,
8787                code_lines: e.summary.code_lines,
8788                comment_lines: e.summary.comment_lines,
8789                blank_lines: e.summary.blank_lines,
8790                physical_lines: e.summary.total_physical_lines,
8791                files_analyzed: e.summary.files_analyzed,
8792                files_skipped: e.summary.files_skipped,
8793                test_count: e.summary.test_count,
8794                project_label: e.project_label.clone(),
8795                html_url,
8796                has_pdf,
8797                submodule_links,
8798                coverage_line_pct,
8799            };
8800            if let Some(ref filter) = submodule_filter {
8801                apply_submodule_filter(base, filter, &e)
8802            } else {
8803                Some(base)
8804            }
8805        })
8806        .collect();
8807
8808    Json(entries).into_response()
8809}
8810
8811/// One scan's code churn versus the previous scan of the same project.
8812#[derive(Serialize)]
8813struct ChurnEntry {
8814    run_id: String,
8815    added: i64,
8816    removed: i64,
8817    modified: i64,
8818    unmodified: i64,
8819}
8820
8821// GET /api/metrics/churn?root=<path>&limit=<n>
8822// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
8823// comparing each scan to the previous scan of the same project. Loads per-file JSON
8824// artifacts, so it is intended for export-time use rather than every page load.
8825async fn api_metrics_churn_handler(
8826    State(state): State<AppState>,
8827    Query(query): Query<MetricsHistoryQuery>,
8828) -> Response {
8829    let limit = query.limit.unwrap_or(200).min(500);
8830    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8831        let reg = state.registry.lock().await;
8832        reg.entries
8833            .iter()
8834            .filter(|e| {
8835                query.root.as_ref().is_none_or(|root| {
8836                    let resolved = resolve_input_path(root);
8837                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8838                    e.input_roots.iter().any(|r| r == &root_str)
8839                })
8840            })
8841            .take(limit)
8842            .cloned()
8843            .collect()
8844    };
8845    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8846        std::collections::HashMap::new();
8847    for e in candidate_entries {
8848        by_project
8849            .entry(e.project_label.clone())
8850            .or_default()
8851            .push(e);
8852    }
8853    let mut out: Vec<ChurnEntry> = Vec::new();
8854    for (_proj, mut entries) in by_project {
8855        entries.sort_by_key(|e| e.timestamp_utc);
8856        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
8857        for e in &entries {
8858            let curr = e
8859                .json_path
8860                .as_ref()
8861                .and_then(|path| sloc_core::read_json(path).ok());
8862            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
8863                let cmp = sloc_core::compute_delta(prev, cur);
8864                out.push(ChurnEntry {
8865                    run_id: e.run_id.clone(),
8866                    added: sum_added_code_lines(&cmp),
8867                    removed: sum_removed_code_lines(&cmp),
8868                    modified: sum_modified_code_lines(&cmp),
8869                    unmodified: sum_unmodified_code_lines(&cmp),
8870                });
8871            } else {
8872                out.push(ChurnEntry {
8873                    run_id: e.run_id.clone(),
8874                    added: 0,
8875                    removed: 0,
8876                    modified: 0,
8877                    unmodified: 0,
8878                });
8879            }
8880            if curr.is_some() {
8881                prev_run = curr;
8882            }
8883        }
8884    }
8885    Json(out).into_response()
8886}
8887
8888// GET /api/metrics/submodules?root=<path>
8889// Returns the union of distinct submodule names found across all saved scan JSON artifacts
8890// for the given project root (or all roots if omitted).
8891#[derive(Deserialize)]
8892struct MetricsSubmodulesQuery {
8893    root: Option<String>,
8894}
8895
8896#[derive(Serialize)]
8897struct SubmoduleEntry {
8898    name: String,
8899    relative_path: String,
8900}
8901
8902async fn api_metrics_submodules_handler(
8903    State(state): State<AppState>,
8904    Query(query): Query<MetricsSubmodulesQuery>,
8905) -> Response {
8906    let json_paths: Vec<std::path::PathBuf> = {
8907        let reg = state.registry.lock().await;
8908        reg.entries
8909            .iter()
8910            .filter(|e| {
8911                query.root.as_ref().is_none_or(|root| {
8912                    let resolved = resolve_input_path(root);
8913                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8914                    e.input_roots.iter().any(|r| r == &root_str)
8915                })
8916            })
8917            .filter_map(|e| e.json_path.clone())
8918            .collect()
8919    };
8920
8921    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8922    let mut result: Vec<SubmoduleEntry> = Vec::new();
8923
8924    for path in &json_paths {
8925        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8926            continue;
8927        };
8928        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8929            continue;
8930        };
8931        for sub in &run.submodule_summaries {
8932            if seen.insert(sub.name.clone()) {
8933                result.push(SubmoduleEntry {
8934                    name: sub.name.clone(),
8935                    relative_path: sub.relative_path.clone(),
8936                });
8937            }
8938        }
8939    }
8940
8941    result.sort_by(|a, b| a.name.cmp(&b.name));
8942    Json(result).into_response()
8943}
8944
8945// ── CI ingest endpoint ────────────────────────────────────────────────────────
8946// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
8947// server stores and displays results without cloning or scanning anything itself.
8948//
8949// POST /api/ingest?label=<optional_display_name>
8950// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
8951// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
8952
8953#[derive(Deserialize)]
8954struct IngestQuery {
8955    label: Option<String>,
8956}
8957
8958#[derive(Serialize)]
8959struct IngestResponse {
8960    run_id: String,
8961    view_url: String,
8962}
8963
8964async fn api_ingest_handler(
8965    State(state): State<AppState>,
8966    Query(q): Query<IngestQuery>,
8967    Json(run): Json<sloc_core::AnalysisRun>,
8968) -> Response {
8969    let label = q.label.unwrap_or_else(|| {
8970        run.input_roots
8971            .first()
8972            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8973    });
8974
8975    let label_for_task = label.clone();
8976    let result = tokio::task::spawn_blocking(move || {
8977        let html = render_html(&run)?;
8978        let run_id = run.tool.run_id.clone();
8979        let run_id_safe = run_id.len() <= 128
8980            && !run_id.is_empty()
8981            && run_id
8982                .chars()
8983                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8984        if !run_id_safe {
8985            anyhow::bail!(
8986                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8987            );
8988        }
8989        let project_label = sanitize_project_label(&label_for_task);
8990        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8991        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8992            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8993            _ => project_label,
8994        };
8995        let (artifacts, _pending_pdf) = persist_run_artifacts(
8996            &run,
8997            &html,
8998            &output_dir,
8999            &label_for_task,
9000            &file_stem,
9001            RunResultContext::default(),
9002        )?;
9003        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9004    })
9005    .await;
9006
9007    match result {
9008        Ok(Ok((run_id, artifacts, run))) => {
9009            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9010            (
9011                StatusCode::CREATED,
9012                Json(IngestResponse {
9013                    view_url: format!("/view-reports?run_id={run_id}"),
9014                    run_id,
9015                }),
9016            )
9017                .into_response()
9018        }
9019        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9020        Err(e) => error::internal(&format!("{e}")),
9021    }
9022}
9023
9024// ── Multi-compare page ────────────────────────────────────────────────────────
9025// GET /multi-compare?runs=id1,id2,id3,...
9026
9027fn html_escape(s: &str) -> String {
9028    s.replace('&', "&amp;")
9029        .replace('<', "&lt;")
9030        .replace('>', "&gt;")
9031        .replace('"', "&quot;")
9032}
9033
9034#[allow(clippy::cast_precision_loss)]
9035fn fmt_num(n: i64) -> String {
9036    let a = n.unsigned_abs();
9037    if a >= 1_000_000 {
9038        let v = n as f64 / 1_000_000.0;
9039        let s = format!("{v:.1}");
9040        format!("{}M", s.trim_end_matches(".0"))
9041    } else if a >= 10_000 {
9042        let v = n as f64 / 1_000.0;
9043        let s = format!("{v:.1}");
9044        format!("{}K", s.trim_end_matches(".0"))
9045    } else {
9046        let sign = if n < 0 { "-" } else { "" };
9047        if a < 1_000 {
9048            return format!("{sign}{a}");
9049        }
9050        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9051    }
9052}
9053
9054fn fmt_comma(n: i64) -> String {
9055    let sign = if n < 0 { "-" } else { "" };
9056    let a = n.unsigned_abs();
9057    if a < 1_000 {
9058        return format!("{sign}{a}");
9059    }
9060    let s = a.to_string();
9061    let bytes = s.as_bytes();
9062    let len = bytes.len();
9063    let mut out = String::with_capacity(len + len / 3);
9064    for (i, &b) in bytes.iter().enumerate() {
9065        if i > 0 && (len - i).is_multiple_of(3) {
9066            out.push(',');
9067        }
9068        out.push(b as char);
9069    }
9070    format!("{sign}{out}")
9071}
9072
9073/// Insert thousands separators into the integer portion of a number's textual form.
9074///
9075/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9076/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9077/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9078/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9079fn group_thousands(s: &str) -> String {
9080    let (sign, rest) = match s.as_bytes().first() {
9081        Some(b'-') => ("-", &s[1..]),
9082        Some(b'+') => ("+", &s[1..]),
9083        _ => ("", s),
9084    };
9085    let (int_part, frac_part) = match rest.split_once('.') {
9086        Some((i, f)) => (i, Some(f)),
9087        None => (rest, None),
9088    };
9089    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9090        return s.to_string();
9091    }
9092    let bytes = int_part.as_bytes();
9093    let len = bytes.len();
9094    let mut grouped = String::with_capacity(len + len / 3);
9095    for (i, &b) in bytes.iter().enumerate() {
9096        if i > 0 && (len - i).is_multiple_of(3) {
9097            grouped.push(',');
9098        }
9099        grouped.push(b as char);
9100    }
9101    frac_part.map_or_else(
9102        || format!("{sign}{grouped}"),
9103        |f| format!("{sign}{grouped}.{f}"),
9104    )
9105}
9106
9107/// Custom Askama filters available to templates in this crate.
9108mod filters {
9109    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9110    // (a `&self` `execute` method returning `Result`), not on our own source.
9111    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9112    use askama::{Result, Values};
9113
9114    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9115    ///
9116    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9117    /// (dashes, "No prior scan", etc.) passes through untouched.
9118    #[askama::filter_fn]
9119    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9120        Ok(super::group_thousands(&value.to_string()))
9121    }
9122}
9123
9124#[derive(Deserialize, Default)]
9125struct MultiCompareQuery {
9126    runs: Option<String>,
9127    /// "super" to show only super-repo files (exclude all submodule files)
9128    scope: Option<String>,
9129    /// Submodule name to narrow the comparison to one submodule
9130    sub: Option<String>,
9131}
9132
9133#[allow(clippy::too_many_lines)]
9134async fn multi_compare_handler(
9135    State(state): State<AppState>,
9136    Query(params): Query<MultiCompareQuery>,
9137    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9138) -> impl IntoResponse {
9139    let run_ids: Vec<String> = params
9140        .runs
9141        .as_deref()
9142        .unwrap_or("")
9143        .split(',')
9144        .map(|s| s.trim().to_string())
9145        .filter(|s| !s.is_empty())
9146        .collect();
9147
9148    if run_ids.len() < 2 {
9149        return Html(
9150            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9151             <a href=\"/compare-scans\">Go back</a></p>",
9152        )
9153        .into_response();
9154    }
9155    if run_ids.len() > 20 {
9156        return Html(
9157            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9158             at once. <a href=\"/compare-scans\">Go back</a></p>",
9159        )
9160        .into_response();
9161    }
9162
9163    // Look up each run_id in the registry.
9164    let entries: Vec<Option<RegistryEntry>> = {
9165        let reg = state.registry.lock().await;
9166        run_ids
9167            .iter()
9168            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9169            .collect()
9170    };
9171
9172    for (i, entry) in entries.iter().enumerate() {
9173        if entry.is_none() {
9174            let html = format!(
9175                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9176                 found. <a href=\"/compare-scans\">Go back</a></p>",
9177                run_ids[i]
9178            );
9179            return Html(html).into_response();
9180        }
9181    }
9182
9183    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9184
9185    for entry in &entries {
9186        if entry.json_path.is_none() {
9187            let html = format!(
9188                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9189                 JSON data — re-run the analysis to enable comparison. \
9190                 <a href=\"/compare-scans\">Go back</a></p>",
9191                &entry.run_id
9192            );
9193            return Html(html).into_response();
9194        }
9195    }
9196
9197    // Sort chronologically.
9198    entries.sort_by_key(|e| e.timestamp_utc);
9199
9200    // Load JSON for each entry.
9201    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9202    for entry in &entries {
9203        let path = entry.json_path.as_ref().unwrap();
9204        match read_json(path) {
9205            Ok(r) => runs.push(r),
9206            Err(e) => {
9207                let html = format!(
9208                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9209                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9210                    &entry.run_id
9211                );
9212                return Html(html).into_response();
9213            }
9214        }
9215    }
9216
9217    // Collect submodule names from all runs.
9218    let all_sub_names: Vec<String> = {
9219        let mut set = std::collections::BTreeSet::new();
9220        for r in &runs {
9221            for s in &r.submodule_summaries {
9222                set.insert(s.name.clone());
9223            }
9224        }
9225        set.into_iter().collect()
9226    };
9227    let has_submodule_data = !all_sub_names.is_empty();
9228    let active_submodule = params.sub.clone();
9229    let super_scope_active = params.scope.as_deref() == Some("super");
9230
9231    // Narrow per_file_records when a scope is active, then recompute totals.
9232    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9233
9234    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9235    let project_label = entries
9236        .first()
9237        .map_or("", |e| e.project_label.as_str())
9238        .to_string();
9239    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9240    let multi = compute_multi_delta(&run_refs);
9241    let html = multi_compare_page(
9242        &multi,
9243        &project_label,
9244        env!("CARGO_PKG_VERSION"),
9245        &csp_nonce,
9246        has_submodule_data,
9247        &all_sub_names,
9248        &runs_csv,
9249        super_scope_active,
9250        active_submodule.as_deref(),
9251        &entries,
9252    );
9253    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9254    // copy after a rebuild would silently mask UI fixes.
9255    (
9256        [(axum::http::header::CACHE_CONTROL, "no-store")],
9257        Html(html),
9258    )
9259        .into_response()
9260}
9261
9262const fn multi_delta_class(n: i64) -> &'static str {
9263    match n {
9264        1.. => "pos",
9265        ..=-1 => "neg",
9266        0 => "zero",
9267    }
9268}
9269
9270fn multi_fmt_delta(n: i64) -> String {
9271    if n > 0 {
9272        format!("+{n}")
9273    } else {
9274        format!("{n}")
9275    }
9276}
9277
9278/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9279fn js_escape(s: &str) -> String {
9280    use std::fmt::Write as _;
9281    let mut out = String::with_capacity(s.len() + 2);
9282    for c in s.chars() {
9283        match c {
9284            '"' => out.push_str("\\\""),
9285            '\\' => out.push_str("\\\\"),
9286            '\n' => out.push_str("\\n"),
9287            '\r' => out.push_str("\\r"),
9288            '\t' => out.push_str("\\t"),
9289            c if (c as u32) < 0x20 => {
9290                let _ = write!(out, "\\u{:04x}", c as u32);
9291            }
9292            c => out.push(c),
9293        }
9294    }
9295    out
9296}
9297
9298/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9299fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9300    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9301        return (
9302            "&mdash;".to_string(),
9303            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9304        );
9305    };
9306    let cd = entry
9307        .git_commit_date
9308        .as_deref()
9309        .and_then(fmt_git_date)
9310        .unwrap_or_else(|| "&mdash;".to_string());
9311    let au = entry.git_author.as_deref().map_or_else(
9312        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9313        |a| {
9314            format!(
9315                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9316                 <span class=\"cmp-author-handle\"></span></span>",
9317                html_escape(a)
9318            )
9319        },
9320    );
9321    (cd, au)
9322}
9323
9324/// Render the scope badge chip for a scan card header.
9325fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9326    active_sub.map_or_else(
9327        || {
9328            if super_scope_active {
9329                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9330            } else {
9331                "<span class=\"mc-scope-tag mc-scope-full\">\
9332                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9333                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9334                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9335                 <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>\
9336                 </svg> Full scan</span>"
9337                    .to_string()
9338            }
9339        },
9340        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9341    )
9342}
9343
9344/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9345fn build_mc_scan_strip(
9346    multi: &MultiScanComparison,
9347    entries: &[RegistryEntry],
9348    n: usize,
9349    is_many: bool,
9350    active_sub: Option<&str>,
9351    super_scope_active: bool,
9352    project_label: &str,
9353) -> String {
9354    use std::fmt::Write as _;
9355    let mut scan_strip = String::new();
9356    for (i, pt) in multi.points.iter().enumerate() {
9357        let ts_ms = pt.timestamp.timestamp_millis();
9358        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9359        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9360        let branch = pt.git_branch.as_deref().unwrap_or("");
9361        let report_link = format!("/runs/html/{}", pt.run_id);
9362        let branch_html = if branch.is_empty() {
9363            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9364        } else {
9365            format!(
9366                "<span class=\"mc-card-branch\">{}</span>",
9367                html_escape(branch)
9368            )
9369        };
9370        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9371        let tags_html = pt
9372            .git_tags
9373            .as_deref()
9374            .filter(|t| !t.is_empty())
9375            .map(|t| {
9376                let chips = t
9377                    .split(',')
9378                    .filter(|s| !s.is_empty())
9379                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9380                    .collect::<Vec<_>>()
9381                    .join(" ");
9382                format!(
9383                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9384                     <span class=\"mc-row-val\">{chips}</span></div>"
9385                )
9386            })
9387            .unwrap_or_default();
9388        let nearest = pt
9389            .git_nearest_tag
9390            .as_deref()
9391            .map(|t| format!("near {}", html_escape(t)))
9392            .unwrap_or_default();
9393        let arrow = if i < n - 1 && !is_many {
9394            "<div class='mc-arrow'>&#8594;</div>"
9395        } else {
9396            ""
9397        };
9398        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9399        let nearest_html = if nearest.is_empty() {
9400            String::new()
9401        } else {
9402            format!(
9403                "<span class=\"mc-card-nearest-wrap\">\
9404                 <span class=\"mc-card-nearest\">{nearest}</span>\
9405                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9406                 </span>"
9407            )
9408        };
9409        write!(
9410            scan_strip,
9411            r#"<div class="mc-card">
9412              <div class="mc-card-header">
9413                <div class="mc-card-num">Scan {num}</div>
9414                <div class="mc-card-project-col">
9415                  <div class="mc-card-project">{project_label}</div>
9416                  {scope_badge}
9417                </div>
9418              </div>
9419              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9420              <div class="mc-card-rows">
9421                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9422                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9423                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9424                <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>
9425                {tags_html}
9426              </div>
9427              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9428            </div>{arrow}"#,
9429            num = i + 1,
9430            commit = html_escape(commit),
9431            commit_date = commit_date_html,
9432            ts_ms = ts_ms,
9433            code = fmt_num(pt.code_lines),
9434            scope_badge = scope_badge,
9435            nearest_html = nearest_html,
9436        )
9437        .unwrap();
9438    }
9439    scan_strip
9440}
9441
9442/// Build the metric progression table (thead + tbody) for multi-compare.
9443#[allow(clippy::too_many_lines)]
9444fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9445    use std::fmt::Write as _;
9446    struct MetricRow<'a> {
9447        label: &'a str,
9448        values: Vec<i64>,
9449        seq_deltas: Vec<i64>,
9450        net_delta: i64,
9451    }
9452    let rows: Vec<MetricRow<'_>> = vec![
9453        MetricRow {
9454            label: "Code Lines",
9455            values: multi.points.iter().map(|p| p.code_lines).collect(),
9456            seq_deltas: multi
9457                .sequential_deltas
9458                .iter()
9459                .map(|d| d.summary.code_lines_delta)
9460                .collect(),
9461            net_delta: multi.total_delta.code_lines_delta,
9462        },
9463        MetricRow {
9464            label: "Files Analyzed",
9465            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9466            seq_deltas: multi
9467                .sequential_deltas
9468                .iter()
9469                .map(|d| d.summary.files_analyzed_delta)
9470                .collect(),
9471            net_delta: multi.total_delta.files_analyzed_delta,
9472        },
9473        MetricRow {
9474            label: "Comment Lines",
9475            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9476            seq_deltas: multi
9477                .sequential_deltas
9478                .iter()
9479                .map(|d| d.summary.comment_lines_delta)
9480                .collect(),
9481            net_delta: multi.total_delta.comment_lines_delta,
9482        },
9483        MetricRow {
9484            label: "Blank Lines",
9485            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9486            seq_deltas: multi
9487                .sequential_deltas
9488                .iter()
9489                .map(|d| d.summary.blank_lines_delta)
9490                .collect(),
9491            net_delta: multi.total_delta.blank_lines_delta,
9492        },
9493        MetricRow {
9494            label: "Tests",
9495            values: multi.points.iter().map(|p| p.test_count).collect(),
9496            seq_deltas: multi
9497                .points
9498                .windows(2)
9499                .map(|pts| pts[1].test_count - pts[0].test_count)
9500                .collect(),
9501            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9502                - multi.points.first().map_or(0, |f| f.test_count),
9503        },
9504    ];
9505    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9506    for i in 0..n {
9507        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9508        if i < n - 1 {
9509            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9510        }
9511    }
9512    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9513    let mut metrics_tbody = String::new();
9514    for row in &rows {
9515        metrics_tbody.push_str("<tr>");
9516        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9517        for i in 0..n {
9518            write!(
9519                metrics_tbody,
9520                "<td class='mc-val-col'>{}</td>",
9521                fmt_comma(row.values[i])
9522            )
9523            .unwrap();
9524            if i < n - 1 {
9525                let d = row.seq_deltas[i];
9526                write!(
9527                    metrics_tbody,
9528                    "<td class='mc-delta-col {cls}'>{val}</td>",
9529                    cls = multi_delta_class(d),
9530                    val = multi_fmt_delta(d)
9531                )
9532                .unwrap();
9533            }
9534        }
9535        let nd = row.net_delta;
9536        write!(
9537            metrics_tbody,
9538            "<td class='mc-net-col {cls}'>{val}</td>",
9539            cls = multi_delta_class(nd),
9540            val = multi_fmt_delta(nd)
9541        )
9542        .unwrap();
9543        metrics_tbody.push_str("</tr>");
9544    }
9545    (metrics_thead, metrics_tbody)
9546}
9547
9548/// Build the JS-embeddable points JSON array for the multi-compare chart.
9549fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9550    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9551    for (i, pt) in multi.points.iter().enumerate() {
9552        let commit = pt.git_commit.as_deref().unwrap_or("");
9553        let branch = pt.git_branch.as_deref().unwrap_or("");
9554        let tags = pt.git_tags.as_deref().unwrap_or("");
9555        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9556        let scanned_ms = pt.timestamp.timestamp_millis();
9557        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9558        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9559        let commit_date = entry
9560            .and_then(|e| e.git_commit_date.as_deref())
9561            .and_then(fmt_git_date)
9562            .unwrap_or_default();
9563        let author = entry
9564            .and_then(|e| e.git_author.as_deref())
9565            .unwrap_or("")
9566            .to_string();
9567        let cov = pt
9568            .coverage_line_pct
9569            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9570        parts.push(format!(
9571            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}}}"#,
9572            run_id = js_escape(&pt.run_id),
9573            commit = js_escape(commit),
9574            branch = js_escape(branch),
9575            tags = js_escape(tags),
9576            nearest = js_escape(nearest),
9577            commit_date = js_escape(&commit_date),
9578            author = js_escape(&author),
9579            scanned = js_escape(&scanned),
9580            code = pt.code_lines,
9581            comments = pt.comment_lines,
9582            blank = pt.blank_lines,
9583            files = pt.files_analyzed,
9584            tests = pt.test_count,
9585        ));
9586    }
9587    format!("[{}]", parts.join(","))
9588}
9589
9590/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
9591fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9592    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9593    for row in &multi.file_matrix {
9594        let lang = row.language.as_deref().unwrap_or("");
9595        let codes: Vec<String> = row
9596            .code_per_scan
9597            .iter()
9598            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9599            .collect();
9600        let deltas: Vec<String> = row
9601            .code_delta_per_scan
9602            .iter()
9603            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9604            .collect();
9605        parts.push(format!(
9606            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9607            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9608            status = row.overall_status,
9609            codes = codes.join(","),
9610            deltas = deltas.join(","),
9611            total = row.total_code_delta,
9612        ));
9613    }
9614    format!("[{}]", parts.join(","))
9615}
9616
9617/// Build the column header cells for the file-matrix table.
9618fn build_mc_file_col_headers(n: usize) -> String {
9619    use std::fmt::Write as _;
9620    let mut out = String::new();
9621    for i in 0..n {
9622        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9623        if i < n - 1 {
9624            write!(
9625                out,
9626                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
9627                i + 2
9628            )
9629            .unwrap();
9630        }
9631    }
9632    out
9633}
9634
9635/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
9636fn build_mc_scope_bar(
9637    has_submodule_data: bool,
9638    sub_names: &[String],
9639    runs_csv: &str,
9640    active_sub: Option<&str>,
9641    super_scope_active: bool,
9642) -> String {
9643    use std::fmt::Write as _;
9644    if !has_submodule_data {
9645        return String::new();
9646    }
9647    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9648    let full_active = active_sub.is_none() && !super_scope_active;
9649    let mut bar = format!(
9650        r#"<div class="submod-scope-bar">
9651  <span class="submod-scope-label">
9652    <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>
9653    Scope:
9654  </span>
9655  <div class="submod-scope-divider"></div>
9656  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9657  <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>"#,
9658        full_cls = if full_active { " active" } else { "" },
9659        super_cls = if super_scope_active { " active" } else { "" },
9660    );
9661    for s in sub_names {
9662        let is_active = active_sub == Some(s.as_str());
9663        write!(
9664            bar,
9665            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9666            cls = if is_active { " active" } else { "" },
9667            name_enc = html_escape(s),
9668            name_esc = html_escape(s),
9669        )
9670        .unwrap();
9671    }
9672    bar.push_str("\n</div>");
9673    bar
9674}
9675
9676/// Build the scope-description label shown in the page subtitle.
9677fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9678    active_sub.map_or_else(
9679        || {
9680            if super_scope_active {
9681                "Super-repo only &mdash; ".to_string()
9682            } else {
9683                String::new()
9684            }
9685        },
9686        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
9687    )
9688}
9689
9690#[allow(clippy::too_many_lines)]
9691#[allow(clippy::too_many_arguments)]
9692fn multi_compare_page(
9693    multi: &MultiScanComparison,
9694    project_label: &str,
9695    version: &str,
9696    csp_nonce: &str,
9697    has_submodule_data: bool,
9698    sub_names: &[String],
9699    runs_csv: &str,
9700    super_scope_active: bool,
9701    active_sub: Option<&str>,
9702    entries: &[RegistryEntry],
9703) -> String {
9704    let n = multi.points.len();
9705    let is_many = n > 4;
9706    let mc_strip_class = if is_many {
9707        "mc-strip mc-strip-grid"
9708    } else {
9709        "mc-strip"
9710    };
9711
9712    // ── Scan strip cards ──────────────────────────────────────────────────────
9713    let scan_strip = build_mc_scan_strip(
9714        multi,
9715        entries,
9716        n,
9717        is_many,
9718        active_sub,
9719        super_scope_active,
9720        project_label,
9721    );
9722
9723    // ── Summary metrics table ─────────────────────────────────────────────────
9724    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9725
9726    // ── Chart data and table helpers ──────────────────────────────────────────
9727    let points_json = build_mc_points_json(multi, entries);
9728    let file_matrix_json = build_mc_file_matrix_json(multi);
9729
9730    // Counts for filter tabs
9731    let files_modified = multi
9732        .file_matrix
9733        .iter()
9734        .filter(|f| f.overall_status == "modified")
9735        .count();
9736    let files_added = multi
9737        .file_matrix
9738        .iter()
9739        .filter(|f| f.overall_status == "added")
9740        .count();
9741    let files_removed = multi
9742        .file_matrix
9743        .iter()
9744        .filter(|f| f.overall_status == "removed")
9745        .count();
9746    let files_unchanged = multi
9747        .file_matrix
9748        .iter()
9749        .filter(|f| f.overall_status == "unchanged")
9750        .count();
9751    let total_files = multi.file_matrix.len();
9752
9753    let file_col_headers = build_mc_file_col_headers(n);
9754    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9755    let scope_bar_html = build_mc_scope_bar(
9756        has_submodule_data,
9757        sub_names,
9758        runs_csv,
9759        active_sub,
9760        super_scope_active,
9761    );
9762    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9763    let toast_assets = sloc_toast_assets(csp_nonce);
9764
9765    format!(
9766        r#"<!doctype html>
9767<html lang="en">
9768<head>
9769  <meta charset="utf-8">
9770  <meta name="viewport" content="width=device-width, initial-scale=1">
9771  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9772  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9773  <style nonce="{csp_nonce}">
9774    :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;}}
9775    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9776    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9777    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;}}
9778    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9779    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9780    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9781    .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;}}
9782    @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));}}}}
9783    .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);}}
9784    .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;}}
9785    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9786    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9787    @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;}}}}
9788    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9789    .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));}}
9790    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9791    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9792    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9793    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9794    .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;}}
9795    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9796    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9797    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9798    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9799    .nav-dropdown{{position:relative;display:inline-flex;}}
9800    .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;}}
9801    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9802    .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;}}
9803    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9804    .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);}}
9805    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9806    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9807    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9808    body:not(.dark-theme) .icon-sun{{display:none;}}
9809    body.dark-theme .icon-moon{{display:none;}}
9810    .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;}}
9811    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9812    .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);}}
9813    .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;}}
9814    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9815    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9816    .settings-modal-body{{padding:14px 16px 16px;}}
9817    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9818    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9819    .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;}}
9820    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9821    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9822    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9823    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9824    .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;}}
9825    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9826    .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;}}
9827    .btn-back:hover{{background:var(--line);}}
9828    .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;}}
9829    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;}}
9830    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9831    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9832    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9833    .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;}}
9834    .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;}}
9835    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9836    .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;}}
9837    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9838    body.dark-theme .mc-card{{background:var(--surface-2);}}
9839    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9840    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9841    .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%;}}
9842    .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;}}
9843    .mc-card-commit:hover{{color:var(--oxide);}}
9844    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9845    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9846    .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;}}
9847    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9848    .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;}}
9849    .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;}}
9850    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9851    .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;}}
9852    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
9853    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
9854    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
9855    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
9856    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
9857    .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);}}
9858    .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);}}
9859    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
9860    .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;}}
9861    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
9862    .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;}}
9863    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
9864    .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;}}
9865    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
9866    .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;}}
9867    .submod-scope-btn:hover{{background:var(--line);}}
9868    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9869    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
9870    .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;}}
9871    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
9872    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9873    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
9874    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
9875    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
9876    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
9877    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
9878    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
9879    .metrics-table .pos{{color:var(--pos);}}
9880    .metrics-table .neg{{color:var(--neg);}}
9881    .metrics-table .zero{{color:var(--muted);}}
9882    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
9883    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9884    .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;}}
9885    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9886    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
9887    .chart-wrap{{width:100%;overflow-x:auto;}}
9888    #mc-chart{{display:block;width:100%;}}
9889    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
9890    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
9891    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
9892    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
9893    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9894    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
9895    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
9896    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
9897    .ic-card-h2-row .ic-card-h2{{margin:0;}}
9898    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9899    .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;}}
9900    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9901    .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;}}
9902    .ic-svg-modal-ov.open{{display:flex;}}
9903    .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);}}
9904    .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);}}
9905    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
9906    .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;}}
9907    .ic-svg-modal-close:hover{{background:var(--line);}}
9908    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
9909    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
9910    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
9911    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
9912    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
9913    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
9914    #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;}}
9915    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9916    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
9917    .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;}}
9918    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9919    .tab-btn:hover:not(.active){{background:var(--line);}}
9920    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
9921    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
9922    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
9923    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
9924    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
9925    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
9926    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
9927    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
9928    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
9929    .table-wrap{{width:100%;overflow-x:auto;}}
9930    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
9931    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
9932    #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;}}
9933    #file-table th.left,#file-table td.left{{text-align:left;}}
9934    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
9935    .file-delta-col{{color:var(--muted);font-size:11px;}}
9936    .file-net-col{{font-weight:800;}}
9937    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
9938    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
9939    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
9940    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
9941    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
9942    .status-badge.modified{{background:#fff2d8;color:#926000;}}
9943    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
9944    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
9945    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
9946    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
9947    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
9948    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
9949    tr.row-added td{{background:rgba(26,143,71,0.04);}}
9950    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9951    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9952    tr.row-unchanged td{{color:var(--muted);}}
9953    tr.row-unchanged .status-badge{{opacity:.65;}}
9954    .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;}}
9955    .absent{{color:var(--muted);font-style:italic;}}
9956    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9957    .pagination-info{{font-size:12px;color:var(--muted);}}
9958    .pagination-btns{{display:flex;gap:5px;}}
9959    .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;}}
9960    .pg-btn:hover:not(:disabled){{background:var(--line);}}
9961    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9962    .pg-btn:disabled{{opacity:.35;cursor:default;}}
9963    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;}}
9964    .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;}}
9965    .export-btn:hover{{background:var(--line);}}
9966    .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;}}
9967    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9968    .site-footer a{{color:var(--muted);}}
9969    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;}}
9970    body.pdf-mode{{background:#fff!important;}}
9971    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
9972    .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;}}
9973    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9974    .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;}}
9975    .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;}}
9976    .mc-modal-title{{font-size:18px;font-weight:800;}}
9977    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9978    .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;}}
9979    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
9980    .mc-modal-body{{padding:18px 22px;}}
9981    .mc-modal-sec{{margin-bottom:20px;}}
9982    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
9983    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
9984    .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;}}
9985    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
9986    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
9987    .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;}}
9988    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
9989    .mc-modal-row:last-child{{border-bottom:none;}}
9990    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9991    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9992    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9993    .mc-modal-val a:hover{{text-decoration:underline;}}
9994    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9995    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9996    .mc-modal-stat[data-tip]{{cursor:help;}}
9997    #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);}}
9998    .mc-card{{cursor:pointer;}}
9999    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10000  </style>
10001</head>
10002<body>
10003  {loading_overlay}
10004  <div class="background-watermarks" aria-hidden="true">
10005    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10006    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10007    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10008    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10009    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10010    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10011  </div>
10012  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10013  <div class="top-nav">
10014    <div class="top-nav-inner">
10015      <a class="brand" href="/">
10016        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10017        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10018      </a>
10019      <div class="nav-right">
10020        <a class="nav-pill" href="/">Home</a>
10021        <div class="nav-dropdown">
10022          <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>
10023          <div class="nav-dropdown-menu">
10024            <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>
10025          </div>
10026        </div>
10027        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10028        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10029        <div class="nav-dropdown">
10030          <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>
10031          <div class="nav-dropdown-menu">
10032            <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>
10033          </div>
10034        </div>
10035        <div class="server-status-wrap" id="server-status-wrap">
10036          <div class="nav-pill server-online-pill" id="server-status-pill">
10037            <span class="status-dot" id="status-dot"></span>
10038            <span id="server-status-label">Server</span>
10039            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10040          </div>
10041          <div class="server-status-tip">
10042            OxideSLOC is running &mdash; accessible on your network.
10043            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10044          </div>
10045        </div>
10046        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10047          <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>
10048        </button>
10049        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10050          <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>
10051          <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>
10052        </button>
10053      </div>
10054    </div>
10055  </div>
10056
10057  <div class="page">
10058    <!-- Hero header -->
10059    <div class="mc-hero">
10060      <div class="mc-hero-header">
10061        <div>
10062          <div class="mc-title">Multi-Scan Timeline</div>
10063          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10064          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10065        </div>
10066        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10067          <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>
10068          <div class="export-group" id="mc-top-export-group">
10069            <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>
10070            <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>
10071          </div>
10072        </div>
10073      </div>
10074      {scope_bar_html}
10075      <!-- Scan strip -->
10076      <div class="{mc_strip_class}">{scan_strip}</div>
10077    </div>
10078
10079    <!-- Summary metrics table -->
10080    <div class="panel">
10081      <div class="panel-title">Metric Progression</div>
10082      <div class="table-wrap">
10083        <table class="metrics-table">
10084          <thead>{metrics_thead}</thead>
10085          <tbody>{metrics_tbody}</tbody>
10086        </table>
10087      </div>
10088    </div>
10089
10090    <!-- Scan Charts -->
10091    <div class="panel" id="mc-charts-panel">
10092      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10093      <div class="ic-grid">
10094        <!-- Timeline line chart — spans full width -->
10095        <div class="ic-card" style="grid-column:span 2">
10096          <div class="ic-card-h2-row">
10097            <span class="ic-card-h2">Timeline</span>
10098            <div class="chart-toolbar" style="margin:0">
10099              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10100              <button class="chart-metric-btn" data-metric="files">Files</button>
10101              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10102              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10103              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10104            </div>
10105          </div>
10106          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10107        </div>
10108        <!-- Code Metrics: Scan 1 vs Latest -->
10109        <div class="ic-card">
10110          <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>
10111          <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>
10112          <div id="mc-ic-c1"></div>
10113        </div>
10114        <!-- Language Code Delta -->
10115        <div class="ic-card" id="mc-ic-lang-card">
10116          <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>
10117          <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>
10118          <div id="mc-ic-c3"></div>
10119        </div>
10120        <!-- Delta by Metric -->
10121        <div class="ic-card">
10122          <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>
10123          <div id="mc-ic-c2"></div>
10124        </div>
10125        <!-- File Change Distribution -->
10126        <div class="ic-card">
10127          <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>
10128          <div id="mc-ic-c4"></div>
10129        </div>
10130      </div>
10131    </div>
10132
10133    <!-- File matrix table -->
10134    <div class="panel">
10135      <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>
10136      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10137        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10138          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10139          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10140          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10141          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10142          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10143        </div>
10144        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10145          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10146          <div class="export-group">
10147          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10148          <button type="button" class="export-btn" id="export-csv-btn">
10149            <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>
10150            CSV
10151          </button>
10152          <button type="button" class="export-btn" id="mc-file-xls-btn">
10153            <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>
10154            Excel
10155          </button>
10156          </div>
10157        </div>
10158      </div>
10159      <div class="table-wrap">
10160        <table id="file-table">
10161          <thead>
10162            <tr>
10163              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10164              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10165              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10166              {file_col_headers}
10167              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10168            </tr>
10169          </thead>
10170          <tbody id="file-tbody"></tbody>
10171        </table>
10172      </div>
10173      <div class="pagination">
10174        <span class="pagination-info" id="pg-info"></span>
10175        <div class="pagination-btns" id="pg-btns"></div>
10176        <div style="display:flex;align-items:center;gap:6px;">
10177          <span style="font-size:12px;color:var(--muted)">Show</span>
10178          <select class="per-page" id="per-page-sel">
10179            <option value="25" selected>25 per page</option>
10180            <option value="50">50 per page</option>
10181            <option value="100">100 per page</option>
10182          </select>
10183        </div>
10184      </div>
10185    </div>
10186  </div>
10187
10188  <div id="mc-ic-tt"></div>
10189
10190  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10191    <div class="ic-svg-modal">
10192      <div class="ic-svg-modal-hdr">
10193        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10194        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10195      </div>
10196      <div id="ic-svg-modal-body"></div>
10197    </div>
10198  </div>
10199
10200  <footer class="site-footer">
10201    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10202    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10203    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10204    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10205    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10206  </footer>
10207
10208  <script nonce="{csp_nonce}">
10209  (function(){{
10210    // ── Dark theme ───────────────────────────────────────────────────────────
10211    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10212    var renderInlineCharts=null;
10213    var tt=document.getElementById('theme-toggle');
10214    if(tt)tt.addEventListener('click',function(){{
10215      var on=document.body.classList.toggle('dark-theme');
10216      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10217      renderChart(activeMetric);
10218      if(renderInlineCharts)renderInlineCharts();
10219    }});
10220
10221    // ── Code particles ───────────────────────────────────────────────────────
10222    var container=document.getElementById('code-particles');
10223    if(container){{
10224      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()'];
10225      for(var i=0;i<28;i++){{
10226        (function(idx){{
10227          var el=document.createElement('span');el.className='code-particle';
10228          el.textContent=snips[idx%snips.length];
10229          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10230          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10231          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10232          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10233          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10234          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10235          container.appendChild(el);
10236        }})(i);
10237      }}
10238    }}
10239
10240    // ── Watermarks ───────────────────────────────────────────────────────────
10241    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10242    if(wms.length){{
10243      var placed=[];
10244      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;}}
10245      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];}}
10246      var half=Math.floor(wms.length/2);
10247      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;}});
10248    }}
10249
10250    // ── Settings / colour scheme modal ───────────────────────────────────────
10251    (function(){{
10252      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'}}];
10253      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);}});}}
10254      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10255      function init(){{
10256        var btn=document.getElementById('settings-btn');if(!btn)return;
10257        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10258        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>';
10259        document.body.appendChild(m);
10260        var g=document.getElementById('scheme-grid');
10261        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);}});
10262        var cl=document.getElementById('settings-close-btn');
10263        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');}});
10264        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10265        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10266      }}
10267      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10268    }})();
10269
10270    // ── Timezone support for scan timestamps ─────────────────────────────────
10271    (function(){{
10272      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';}};
10273      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'';}}}};
10274      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);}});}};
10275      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10276      window.applyTz(storedTz);
10277      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
10278      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10279    }})();
10280
10281    // ── Data ────────────────────────────────────────────────────────────────
10282    var POINTS={points_json};
10283    var FILES={file_matrix_json};
10284    var N={n};
10285
10286    // ── fmt helper ───────────────────────────────────────────────────────────
10287    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();}}
10288    function fmtFull(n){{return Number(n).toLocaleString();}}
10289    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10290
10291    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10292    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10293    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));}}
10294    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10295    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10296
10297    // ── Timeline chart ───────────────────────────────────────────────────────
10298    var activeMetric='code';
10299    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10300    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10301
10302    function renderChart(metric){{
10303      var svg=document.getElementById('mc-chart');if(!svg)return;
10304      var W=svg.getBoundingClientRect().width||800,H=280;
10305      svg.setAttribute('height',H);
10306      var pad={{l:62,r:20,t:32,b:72}};
10307      var dark=document.body.classList.contains('dark-theme');
10308      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10309      var valid=pts.filter(function(v){{return v!=null;}});
10310      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;}}
10311      var minV=0,maxV=Math.max.apply(null,valid);
10312      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10313      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10314      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10315      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10316      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10317      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10318      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10319      var parts=[];
10320      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10321      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>');}}
10322      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10323      var lineD='';var firstPt=true;
10324      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);}}}}
10325      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10326      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10327      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10328      for(var i=0;i<N;i++){{
10329        if(pts[i]==null)continue;
10330        var cx=xOf(i),cy=yOf(pts[i]);
10331        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10332        var hasTag=p.tags&&p.tags.length>0;
10333        // Permanent Y-value label above the dot
10334        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>');
10335        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+'"/>');
10336        var xanchor=i===0?'start':i===N-1?'end':'middle';
10337        // X-axis label at 2× the original size (18 px)
10338        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>');
10339      }}
10340      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10341      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10342      svg.innerHTML=parts.join('');
10343      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');}});
10344      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10345      svg.onmousemove=function(e){{
10346        var rect=svg.getBoundingClientRect();
10347        var scaleX=W/rect.width;
10348        var mouseX=(e.clientX-rect.left)*scaleX;
10349        var nearest=-1,minDist=Infinity;
10350        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;}}}}
10351        if(nearest<0)return;
10352        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10353        var xhair=svg.querySelector('.mc-xhair');
10354        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10355        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"/>';
10356        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10357        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10358        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>';
10359        var bx=rect.left+(nc/W*rect.width)+18;
10360        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10361        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10362      }};
10363      svg.onmouseleave=function(){{
10364        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10365        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10366      }};
10367    }}
10368
10369    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10370
10371    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10372      btn.addEventListener('click',function(){{
10373        activeMetric=this.dataset.metric;
10374        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10375        this.classList.add('active');
10376        renderChart(activeMetric);
10377      }});
10378    }});
10379    if(typeof ResizeObserver!=='undefined'){{
10380      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10381    }}
10382    renderChart(activeMetric);
10383
10384    // ── File matrix table ────────────────────────────────────────────────────
10385    var activeStatus='';
10386    var currentPage=1;
10387    var perPage=25;
10388    var mcSortCol=null,mcSortAsc=true;
10389
10390    function getFiltered(){{
10391      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10392      if(!mcSortCol)return data;
10393      var asc=mcSortAsc;
10394      return data.slice().sort(function(a,b){{
10395        var va,vb;
10396        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10397        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10398        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10399        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10400        else{{return 0;}}
10401        if(asc)return va<vb?-1:va>vb?1:0;
10402        return va<vb?1:va>vb?-1:0;
10403      }});
10404    }}
10405
10406    function renderFilePage(){{
10407      var filtered=getFiltered();
10408      var total=filtered.length;
10409      var totalPages=Math.max(1,Math.ceil(total/perPage));
10410      if(currentPage>totalPages)currentPage=totalPages;
10411      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10412      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10413      var rows=[];
10414      for(var i=start;i<end;i++){{
10415        var f=filtered[i];
10416        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10417        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10418        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10419        for(var j=0;j<N;j++){{
10420          var cv=f.c[j];
10421          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10422          if(j<N-1){{
10423            var dv=f.d[j+1];
10424            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10425              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10426          }}
10427        }}
10428        var tc=f.t;
10429        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10430        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10431      }}
10432      tbody.innerHTML=rows.join('');
10433
10434      var info=document.getElementById('pg-info');
10435      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10436      renderPgBtns(totalPages);
10437    }}
10438
10439    function renderPgBtns(totalPages){{
10440      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10441      var btns=[];
10442      function mkBtn(label,page,active,disabled){{
10443        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10444        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10445      }}
10446      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10447      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10448      if(s>1)btns.push(mkBtn('1',1,false,false));
10449      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10450      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10451      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10452      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10453      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10454      wrap.innerHTML=btns.join('');
10455      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10456        b.addEventListener('click',function(){{
10457          var pg=parseInt(this.dataset.pg,10);
10458          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10459        }});
10460      }});
10461    }}
10462
10463    // Tab filter
10464    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10465      btn.addEventListener('click',function(){{
10466        activeStatus=this.dataset.status||'';
10467        currentPage=1;
10468        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10469        this.classList.add('active');
10470        renderFilePage();
10471      }});
10472    }});
10473
10474    // Per-page selector
10475    var ppSel=document.getElementById('per-page-sel');
10476    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10477
10478    // ── Column header sort ───────────────────────────────────────────────────
10479    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10480      th.addEventListener('click',function(){{
10481        var col=th.dataset.sortCol;
10482        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10483        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10484          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10485        }});
10486        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10487        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10488        currentPage=1;renderFilePage();
10489      }});
10490    }});
10491
10492    // Reset button also clears sort
10493    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10494    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10495      mcSortCol=null;mcSortAsc=true;
10496      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10497        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10498      }});
10499      activeStatus='';currentPage=1;
10500      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10501      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10502      renderFilePage();
10503    }});
10504
10505    renderFilePage();
10506
10507    // ── CSV export ───────────────────────────────────────────────────────────
10508    var exportBtn=document.getElementById('export-csv-btn');
10509    if(exportBtn)exportBtn.addEventListener('click',function(){{
10510      var header=['File','Language','Status'];
10511      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10512      header.push('Net Delta');
10513      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10514      var filtered=getFiltered();
10515      filtered.forEach(function(f){{
10516        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10517        for(var j=0;j<N;j++){{
10518          cols.push(f.c[j]!=null?f.c[j]:'');
10519          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10520        }}
10521        cols.push(f.t);
10522        rows.push(cols.join(','));
10523      }});
10524      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10525      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10526      a.download=mcExportName('csv');a.click();
10527    }});
10528
10529    // ── File matrix extra export buttons ─────────────────────────────────────
10530    (function(){{
10531      var resetBtn=document.getElementById('mc-file-reset-btn');
10532      if(resetBtn)resetBtn.addEventListener('click',function(){{
10533        activeStatus='';currentPage=1;
10534        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10535        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10536        renderFilePage();
10537      }});
10538
10539      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10540      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10541      function mcMakeXlsx(fname){{
10542        var filtered=getFiltered();
10543        var enc=new TextEncoder();
10544        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;}}
10545        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;}}
10546        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10547        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10548        var ss=[],si={{}};
10549        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10550        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10551        function WS(){{
10552          var R=0,buf=[];
10553          function cl(c){{return String.fromCharCode(65+c);}}
10554          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10555          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10556          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10557          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>';}}
10558          return{{sc:sc,nc:nc,row:row,xml:xml}};
10559        }}
10560        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10561        var proj=mcExportProj();
10562        // \u2500\u2500 Summary sheet \u2500\u2500
10563        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10564        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10565        r1(s1(0,proj,2));
10566        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10567        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
10568        r1('');
10569        r1(s1(0,'SCAN SUMMARY',8));
10570        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));
10571        POINTS.forEach(function(p,i){{
10572          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10573          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));
10574        }});
10575        r1('');
10576        if(POINTS.length>1){{
10577          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10578          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10579          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10580          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)));}};
10581          nr('Code Lines',pf.code,pl.code);
10582          nr('Comment Lines',pf.comments,pl.comments);
10583          nr('Files Analyzed',pf.files,pl.files);
10584          nr('Tests',pf.tests,pl.tests);
10585          r1('');
10586        }}
10587        var cMod=0,cAdd=0,cRem=0,cUnch=0;
10588        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10589        var totF=FILES.length||1;
10590        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10591        r1(s1(0,'FILE CHANGES',8));
10592        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10593        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10594        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10595        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10596        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10597        var lm={{}};
10598        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;}});
10599        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10600        if(langs.length){{
10601          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10602          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10603          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)));}});
10604        }}
10605        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10606        // \u2500\u2500 File Delta sheet \u2500\u2500
10607        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10608        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10609        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);}}
10610        hcells+=s2(hc,'Net Delta',3);
10611        r2(hcells);
10612        filtered.forEach(function(f){{
10613          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10614          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));}}}}
10615          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10616          r2(cells);
10617        }});
10618        var ncols=3+N+(N-1)+1;
10619        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10620        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>';
10621        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10622        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>',
10623          '_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>',
10624          '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>',
10625          '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>',
10626          '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>',
10627          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10628        var zparts=[],zcds=[],zoff=0,znf=0;
10629        ['[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){{
10630          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10631          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]);
10632          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);
10633          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));
10634          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10635          zoff+=entry.length;znf++;
10636        }});
10637        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10638        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]);
10639        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10640        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10641        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10642        out.set(new Uint8Array(eocd),pos);
10643        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10644        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10645      }}
10646
10647      var xlsBtn=document.getElementById('mc-file-xls-btn');
10648      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10649
10650      // File matrix HTML export — interactive: sort by column, filter by status
10651      function mcFileBuildHtml(){{
10652        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10653        var hdrs=['File','Language','Status'];
10654        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10655        hdrs.push('Net \u0394');
10656        var SI=2;
10657        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;}});
10658        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10659        var cnt={{all:allRows.length}};
10660        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10661        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10662        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10663          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10664          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10665          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10666          '.sub{{font-size:12px;color:#99aabb;}}'+
10667          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10668          '.wr{{padding:16px 20px;}}'+
10669          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10670          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10671          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10672          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10673          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10674          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10675          'thead tr{{background:#1a2035;}}'+
10676          '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;}}'+
10677          'th:hover{{background:#2a3050;}}'+
10678          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10679          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10680          'tr:nth-child(even) td{{background:#faf7f4;}}'+
10681          'tr:hover td{{background:#f5f0ea;}}'+
10682          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10683          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10684        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10685        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10686          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10687          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10688          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10689          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10690        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10691          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
10692          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
10693          '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>";}}'+
10694          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10695          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10696          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
10697          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10698          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10699          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10700          '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("")'+
10701          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10702          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10703          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10704          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10705          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10706          'sd=(sc===ci)?-sd:1;sc=ci;'+
10707          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10708          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10709          'render();';
10710        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10711          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10712          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
10713          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10714          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10715          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10716          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10717          '<script>'+inlineJs+'<\/script><\/body><\/html>';
10718      }}
10719
10720      var htmlBtn=document.getElementById('mc-file-html-btn');
10721      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10722        var h=mcFileBuildHtml();
10723        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10724        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10725        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10726      }});
10727
10728      var pdfBtn=document.getElementById('mc-file-pdf-btn');
10729      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10730        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10731      }});
10732    }})();
10733
10734    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10735    (function(){{
10736      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10737      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10738      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10739      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10740      function fmt2(n){{return Number(n).toLocaleString();}}
10741      function px(n){{return Math.round(n);}}
10742      var _tt=document.getElementById('mc-ic-tt');
10743      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10744      function addTT(el){{
10745        if(!el)return;
10746        el.addEventListener('mouseover',function(e){{
10747          var t=e.target.closest('[data-ttl]');
10748          if(t&&_tt){{
10749            var ttl=t.getAttribute('data-ttl');
10750            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10751            _tt.style.display='block';mvTT(e);
10752            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10753            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10754          }} else {{
10755            if(_tt)_tt.style.display='none';
10756            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10757          }}
10758        }});
10759        el.addEventListener('mouseleave',function(){{
10760          if(_tt)_tt.style.display='none';
10761          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10762        }});
10763        el.addEventListener('mousemove',function(e){{mvTT(e);}});
10764      }}
10765      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';}}
10766      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10767      function buildCharts(){{
10768        if(N<2)return;
10769        var cs=getComputedStyle(document.body);
10770        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10771        var textCol=cv('--text','#43342d');
10772        var mutedCol=cv('--muted','#7b675b');
10773        var gFill=cv('--muted-2','#a08777');
10774        var LGY=cv('--line','#e6d0bf');
10775        var axisCol=cv('--line-strong','#d8bfad');
10776        var surf2col=cv('--surface-2','#f4ede4');
10777        var surfCol=cv('--surface','#fff8f0');
10778        var p0=POINTS[0],pLast=POINTS[N-1];
10779        var dark=document.body.classList.contains('dark-theme');
10780        var FADE=dark?'#524238':'#e6d0bf';
10781        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10782        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;}}
10783      var c1mets=[
10784        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10785        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10786        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10787      ];
10788      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10789      // Code Metrics chart — grows to fill the height its grid row settled to (the
10790      // Language Code Delta sibling usually drives that), so it never sits short at
10791      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10792      function drawC1(){{
10793        var C1W=620,C1H=200;
10794        var c1host=document.getElementById('mc-ic-c1');
10795        var c1card=c1host?c1host.closest('.ic-card'):null;
10796        if(c1host&&c1card&&c1host.clientWidth>0){{
10797          var avW=c1host.clientWidth;
10798          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10799          var wantH=availPx*C1W/avW;
10800          if(wantH>C1H)C1H=wantH;
10801        }}
10802        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10803        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10804        for(var gi=1;gi<=4;gi++){{
10805          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10806          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10807          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10808        }}
10809        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10810        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10811        c1mets.forEach(function(m,i){{
10812          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10813          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10814          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10815          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;"/>';
10816          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>';
10817          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;"/>';
10818          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>';
10819          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>';
10820          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>';
10821        }});
10822        c1+='</svg>';
10823        return c1;
10824      }}
10825      // Chart 2: Delta by Metric (net delta first scan to last)
10826      var mets=[
10827        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10828        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10829        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10830      ];
10831      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10832      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;
10833      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10834      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10835      mets.forEach(function(m,i){{
10836        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);
10837        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>';
10838        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;"/>';
10839        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>';}}
10840        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>';}}
10841      }});
10842      c2+='</svg>';
10843      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10844      var lm={{}};
10845      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;}});
10846      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10847      function drawC3(){{
10848        if(!langs.length)return'';
10849        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10850        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;
10851        var c3host=document.getElementById('mc-ic-c3');
10852        var c3card=document.getElementById('mc-ic-lang-card');
10853        var C3H=langs.length*30+24;
10854        if(c3host&&c3card&&c3host.clientWidth>0){{
10855          var avW=c3host.clientWidth;
10856          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
10857          var wantH=availPx*C3W/avW;
10858          if(wantH>C3H)C3H=wantH;
10859        }}
10860        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
10861        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10862        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10863        langs.forEach(function(l,i){{
10864          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);
10865          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
10866          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"/>';
10867          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>';}}
10868          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>';}}
10869          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>';
10870        }});
10871        c3+='</svg>';
10872        return c3;
10873      }}
10874      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
10875      var fm=0,fa=0,fr=0,fu=0;
10876      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
10877      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;}});
10878      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
10879      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
10880      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
10881      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;
10882      if(segs.length===1){{
10883        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"/>';
10884        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
10885        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>';
10886      }} else {{
10887        segs.forEach(function(s){{
10888          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
10889          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);
10890          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);
10891          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"/>';
10892          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>';}}
10893          ang4+=sw;
10894        }});
10895      }}
10896      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
10897      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
10898      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
10899      segs.forEach(function(s,i){{
10900        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
10901        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;"/>';
10902        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>';
10903        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
10904      }});
10905      c4+='</svg>';
10906      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
10907      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
10908      // once at natural height to seed the row, then both are filled to the row the
10909      // grid settled to, so neither sits short at the top of an over-tall cell.
10910      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
10911      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
10912      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
10913      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
10914      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>';
10915      if(e1)e1.innerHTML=drawC1();
10916      }}
10917      buildCharts();
10918      renderInlineCharts=buildCharts;
10919      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
10920      (function(){{
10921        var ov=document.getElementById('ic-svg-modal-ov');
10922        var body=document.getElementById('ic-svg-modal-body');
10923        var ttl=document.getElementById('ic-svg-modal-title');
10924        var closeBtn=document.getElementById('ic-svg-modal-close');
10925        if(!ov||!body)return;
10926        function close(){{ov.classList.remove('open');body.innerHTML='';}}
10927        function open(srcId,title){{
10928          var src=document.getElementById(srcId);if(!src)return;
10929          ttl.textContent=title||'';
10930          var card=src.closest('.ic-card');
10931          var legHtml='';
10932          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
10933          body.innerHTML=legHtml+src.innerHTML;
10934          var svg=body.querySelector('svg');
10935          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
10936          addTT(body);
10937          ov.classList.add('open');
10938        }}
10939        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
10940          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
10941        }});
10942        if(closeBtn)closeBtn.addEventListener('click',close);
10943        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
10944        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
10945      }})();
10946
10947      // HTML legend hover → highlight matching SVG bars within the SAME card only
10948      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
10949        var metric=leg.getAttribute('data-highlight');
10950        var parentCard=leg.closest('.ic-card');
10951        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
10952        if(!chartEl)return;
10953        leg.addEventListener('mouseenter',function(){{
10954          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
10955            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
10956              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
10957              x.style.opacity='1';
10958            }} else {{
10959              x.style.opacity='0.28';
10960            }}
10961          }});
10962        }});
10963        leg.addEventListener('mouseleave',function(){{
10964          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10965        }});
10966      }});
10967      // Author handles
10968      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
10969
10970      // ── Export helpers ────────────────────────────────────────────────────────
10971      // Fetch one image from the server and return a data-URI Promise
10972      function mcFetchUri(path){{
10973        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
10974          return new Promise(function(res){{
10975            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
10976          }});
10977        }}).catch(function(){{return '';}});
10978      }}
10979      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
10980      function mcInlineImgs(html,cb){{
10981        var paths=[],seen={{}};
10982        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
10983        if(!paths.length){{cb(html);return;}}
10984        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
10985          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
10986          .catch(function(){{cb(html);}});
10987      }}
10988      // Capture full-page HTML with all table rows visible
10989      function mcRawHtml(pdfMode){{
10990        if(pdfMode)document.body.classList.add('pdf-mode');
10991        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
10992        var html=document.documentElement.outerHTML;
10993        perPage=s;currentPage=p;renderFilePage();
10994        if(pdfMode)document.body.classList.remove('pdf-mode');
10995        return html;
10996      }}
10997
10998      // HTML export (full page with inlined images)
10999      function mcDoHtml(btn,fname){{
11000        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11001        mcInlineImgs(mcRawHtml(false),function(html){{
11002          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11003          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11004          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11005          btn.disabled=false;btn.innerHTML=orig;
11006        }});
11007      }}
11008      // PDF export — comprehensive document-style report: full numbers, all sections
11009      function mcBuildPdfHtml(){{
11010        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11011        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11012        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11013        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>';}}
11014        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11015        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11016        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)));}}
11017        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11018        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11019        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11020        // Header/footer flow in document order (NOT position:fixed) — a fixed
11021        // header repeats every printed page in Chromium and overlaps the content
11022        // below it, swallowing the first rows of pages 2+ and clipping the cards
11023        // on page 1. The table <thead> repeats per page natively, so every row
11024        // stays visible.
11025        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11026          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11027          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11028          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11029          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11030          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11031          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11032          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11033          '.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;}}'+
11034          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11035          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11036          '.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;}}'+
11037          '.body{{padding:12px 18px 0;}}'+
11038          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11039          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11040          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11041          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11042          '.sec{{margin-bottom:10px;}}'+
11043          '.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;}}'+
11044          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11045          '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;}}'+
11046          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11047          'tr:nth-child(even) td{{background:#faf8f6;}}';
11048        // ── Metric Progression ────────────────────────────────────────────────
11049        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11050        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11051        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>';
11052        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11053        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11054        var progRows=POINTS.map(function(pt,i){{
11055          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)));
11056          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11057            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11058            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11059            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11060            '<td style="text-align:right">'+full(pt.files)+'</td>';
11061          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11062          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11063          return r+'</tr>';
11064        }}).join('');
11065        // ── Scan-to-scan changes ──────────────────────────────────────────────
11066        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11067          var prev=POINTS[i];
11068          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11069          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11070          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11071            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11072            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11073            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11074            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11075        }}).join(''):'';
11076        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11077        var fmSection='';
11078        if(FILES&&FILES.length){{
11079          // Hard cap on per-scan columns so the table never overflows the page width.
11080          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11081          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11082          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11083          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11084          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11085          var fmRows=topFiles.map(function(f){{
11086            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11087            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>';
11088            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11089            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11090            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>';
11091          }}).join('');
11092          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11093          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11094            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11095        }}
11096        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11097          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11098          '<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>'+
11099
11100          '<div class="body">'+
11101          '<div class="sg">'+
11102          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11103            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11104          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11105            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11106          (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>':
11107            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11108          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11109          '</div>'+
11110          '<div class="sec"><p class="sh">Metric Progression</p>'+
11111          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11112          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11113          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11114          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11115          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11116          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11117          fmSection+
11118          '</div>'+
11119          '<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>'+
11120          '</body></html>';
11121      }}
11122      function mcDoPdf(btn){{
11123        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11124      }}
11125
11126      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11127      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11128      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11129      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11130      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11131      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11132      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11133      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11134      if(location.protocol==='file:'){{
11135        [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';}}}} );
11136        [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';}}}} );
11137      }}
11138    }})();
11139    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11140    (function(){{
11141      function $(id){{return document.getElementById(id);}}
11142      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11143      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11144      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11145      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11146      function openModal(idx){{
11147        var ov=$('mc-modal-overlay');if(!ov)return;
11148        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11149        if(idx<0||idx>=N)return;
11150        var pt=POINTS[idx];
11151        titleEl.textContent='Scan '+(idx+1);
11152        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11153        subEl.textContent=lbl;
11154        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11155          '<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>'+
11156          '<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>'+
11157          '<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>'+
11158          '<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>'+
11159          (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>':'')+
11160          (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>':'')+
11161          '</div></div>';
11162        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11163          (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>':'')+
11164          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11165          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11166          (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>':'')+
11167          (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>':'')+
11168          (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>':'')+
11169          (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>':'')+
11170          '<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>'+
11171          '</div>';
11172        var dHtml='';
11173        if(idx>0){{
11174          var prev=POINTS[idx-1];
11175          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11176          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11177            '<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>'+
11178            '<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>'+
11179            '<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>'+
11180            '</div></div>';
11181        }}
11182        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11183        ov.classList.add('open');document.body.style.overflow='hidden';
11184      }}
11185      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11186      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11187      document.addEventListener('click',function(e){{
11188        if(!e.target||!e.target.closest)return;
11189        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11190        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11191        var card=e.target.closest('.mc-card');
11192        if(!card)return;
11193        if(e.target.closest('a'))return;
11194        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11195        var i=cards.indexOf(card);
11196        if(i>=0)openModal(i);
11197      }});
11198      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11199      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11200      var statTip=null;
11201      document.addEventListener('mousemove',function(e){{
11202        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11203        if(!box){{if(statTip)statTip.style.display='none';return;}}
11204        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11205        var tip=box.getAttribute('data-tip')||'';
11206        if(statTip.textContent!==tip)statTip.textContent=tip;
11207        statTip.style.display='block';
11208        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11209        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11210        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11211        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11212      }});
11213      (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');}})();
11214    }})();
11215  }})();
11216  </script>
11217  <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]';
11218  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;}}
11219  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>
11220  <!-- Scan card detail modal -->
11221  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11222    <div class="mc-modal" id="mc-modal">
11223      <div class="mc-modal-head">
11224        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11225        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11226      </div>
11227      <div class="mc-modal-body" id="mc-modal-body"></div>
11228    </div>
11229  </div>
11230  {toast_assets}
11231</body>
11232</html>"#,
11233        project_label = html_escape(project_label),
11234        n = n,
11235        scan_strip = scan_strip,
11236        mc_strip_class = mc_strip_class,
11237        metrics_thead = metrics_thead,
11238        metrics_tbody = metrics_tbody,
11239        file_col_headers = file_col_headers,
11240        total_files = total_files,
11241        files_modified = files_modified,
11242        files_added = files_added,
11243        files_removed = files_removed,
11244        files_unchanged = files_unchanged,
11245        points_json = points_json,
11246        file_matrix_json = file_matrix_json,
11247        nav_compare_active = nav_compare_active,
11248        version = version,
11249        csp_nonce = csp_nonce,
11250        scope_bar_html = scope_bar_html,
11251        scope_label = scope_label,
11252        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11253    )
11254}
11255
11256// ── Trend report page ─────────────────────────────────────────────────────────
11257// Protected. Interactive time-series chart page that loads scan history via
11258// /api/metrics/history and renders a vanilla-SVG line chart.
11259//
11260// GET /trend-reports
11261
11262#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11263async fn trend_report_handler(
11264    State(state): State<AppState>,
11265    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11266) -> Response {
11267    auto_scan_watched_dirs(&state).await;
11268
11269    let watched_dirs_list: Vec<String> = {
11270        let wd = state.watched_dirs.lock().await;
11271        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11272    };
11273
11274    // Collect distinct project roots for the root selector dropdown.
11275    let roots: Vec<String> = {
11276        let reg = state.registry.lock().await;
11277        let mut seen = std::collections::BTreeSet::new();
11278        reg.entries
11279            .iter()
11280            .flat_map(|e| e.input_roots.iter().cloned())
11281            .filter(|r| seen.insert(r.clone()))
11282            .collect()
11283    };
11284
11285    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11286    let nonce = &csp_nonce;
11287    let version = env!("CARGO_PKG_VERSION");
11288    let toast_assets = sloc_toast_assets(nonce);
11289
11290    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11291    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11292    // of interactive controls — folder watching is managed by the host administrator.
11293    let watched_dirs_html: String = if state.server_mode {
11294        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()
11295    } else {
11296        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11297            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11298                .to_string()
11299        } else {
11300            watched_dirs_list
11301                .iter()
11302                .fold(String::new(), |mut s, d| {
11303                    use std::fmt::Write as _;
11304                    let escaped =
11305                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11306                    write!(
11307                        s,
11308                        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>"#
11309                    ).expect("write to String is infallible");
11310                    s
11311                })
11312        };
11313        format!(
11314            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>"#
11315        )
11316    };
11317
11318    let html = format!(
11319        r##"<!doctype html>
11320<html lang="en">
11321<head>
11322  <meta charset="utf-8" />
11323  <meta name="viewport" content="width=device-width, initial-scale=1" />
11324  <title>OxideSLOC | Trend Reports</title>
11325  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11326  <style nonce="{nonce}">
11327    :root {{
11328      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11329      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11330      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11331      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11332      --info-bg:#eef3ff; --info-text:#4467d8;
11333    }}
11334    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11335    *{{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;}}
11336    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11337    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11338    .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;}}
11339    @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));}}}}
11340    .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);}}
11341    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11342    .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));}}
11343    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11344    .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;}}
11345    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11346    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11347    @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; }} }}
11348    .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;}}
11349    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11350    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11351    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11352    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11353    .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;}}
11354    .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;}}
11355    .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;}}
11356    .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;}}
11357    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11358    .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);}}
11359    .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;}}
11360    .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;}}
11361    .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;}}
11362    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11363    .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;}}
11364    .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);}}
11365    .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;}}
11366    .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;}}
11367    .tz-select:focus{{border-color:var(--oxide);}}
11368    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11369    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11370    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11371    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11372    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11373    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11374    .trend-title-block{{flex:1;min-width:0;}}
11375    .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;}}
11376    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11377    .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;}}
11378    .chart-select:focus{{border-color:var(--accent);}}
11379    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11380    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11381    .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);}}
11382    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11383    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11384    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11385    .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);}}
11386    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11387    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11388    .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;}}
11389    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11390    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11391    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11392    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11393    .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;}}
11394    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11395    .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;}}
11396    .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);}}
11397    .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;}}
11398    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11399    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11400    .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);}}
11401    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11402    .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;}}
11403    .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;}}
11404    .data-table tr:last-child td{{border-bottom:none;}}
11405    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11406    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11407    .table-wrap{{width:100%;overflow-x:auto;}}
11408    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11409    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11410    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11411    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11412    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11413    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11414    .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;}}
11415    .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;}}
11416    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11417    .pagination-info{{font-size:13px;color:var(--muted);}}
11418    .pagination-btns{{display:flex;gap:6px;}}
11419    .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;}}
11420    .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;}}
11421    #scan-history-table col:nth-child(1){{width:155px;}}
11422    #scan-history-table col:nth-child(2){{width:240px;}}
11423    #scan-history-table col:nth-child(3){{width:82px;}}
11424    #scan-history-table col:nth-child(4){{width:82px;}}
11425    #scan-history-table col:nth-child(5){{width:90px;}}
11426    #scan-history-table col:nth-child(6){{width:90px;}}
11427    #scan-history-table col:nth-child(7){{width:88px;}}
11428    #scan-history-table col:nth-child(8){{width:150px;}}
11429    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11430    .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;}}
11431    .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;}}
11432    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11433    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11434    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11435    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11436    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11437    .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;}}
11438    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11439    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11440    .watched-chip-rm:hover{{color:var(--oxide);}}
11441    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11442    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11443    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11444    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11445    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11446    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11447    a.run-link:hover{{text-decoration:underline;}}
11448    .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);}}
11449    .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);}}
11450    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11451    .metric-num{{font-weight:700;color:var(--text);}}
11452    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11453    .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;}}
11454    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11455    .btn.primary:hover{{opacity:.9;}}
11456    .rpt-btn{{min-width:58px;justify-content:center;}}
11457    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11458    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11459    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11460    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11461    .submod-details summary::-webkit-details-marker{{display:none;}}
11462    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11463    .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;}}
11464    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11465    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11466    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11467    .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;}}
11468    .export-btn:hover{{background:var(--line);}}
11469    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11470    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11471    .site-footer a{{color:var(--muted);}}
11472    .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;}}
11473    .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;}}
11474    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11475    /* Modal system (Retention Policy / Clean-up) */
11476    .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;}}
11477    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11478    .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);}}
11479    .tr-modal{{background:rgba(255,255,255,0.90);}}
11480    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11481    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11482    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11483    .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);}}
11484    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11485    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11486    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11487    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11488    .tr-modal-body{{padding:22px 30px;}}
11489    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11490    .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;}}
11491    .tr-btn:hover{{transform:translateY(-1px);}}
11492    .tr-btn:active{{transform:translateY(0);}}
11493    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11494    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11495    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11496    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11497    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11498    .tr-btn-secondary:hover{{background:var(--line);}}
11499    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11500    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11501  </style>
11502</head>
11503<body>
11504  <div class="background-watermarks" aria-hidden="true">
11505    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11506    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11507    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11508    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11509    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11510    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11511  </div>
11512  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11513  <div class="top-nav">
11514    <div class="top-nav-inner">
11515      <a class="brand" href="/">
11516        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11517        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11518      </a>
11519      <div class="nav-right">
11520        <a class="nav-pill" href="/">Home</a>
11521        <div class="nav-dropdown">
11522          <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>
11523          <div class="nav-dropdown-menu">
11524            <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>
11525          </div>
11526        </div>
11527        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11528        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11529        <div class="nav-dropdown">
11530          <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>
11531          <div class="nav-dropdown-menu">
11532            <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>
11533          </div>
11534        </div>
11535        <div class="server-status-wrap" id="server-status-wrap">
11536          <div class="nav-pill server-online-pill" id="server-status-pill">
11537            <span class="status-dot" id="status-dot"></span>
11538            <span id="server-status-label">Server</span>
11539            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11540          </div>
11541          <div class="server-status-tip">
11542            OxideSLOC is running — accessible on your network.
11543            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11544          </div>
11545        </div>
11546        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11547          <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>
11548        </button>
11549        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11550          <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>
11551          <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>
11552        </button>
11553      </div>
11554    </div>
11555  </div>
11556
11557  <div class="page">
11558    {watched_dirs_html}
11559    <div class="summary-strip" id="trend-stats"></div>
11560    <div class="panel">
11561      <div class="trend-header">
11562        <div class="trend-title-block">
11563          <h1>Trend Reports</h1>
11564          <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>
11565          <span class="chart-hint-inline">
11566            <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>
11567            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
11568          </span>
11569        </div>
11570        <div class="chart-actions">
11571          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11572            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11573            Retention Policy
11574          </button>
11575          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11576            <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>
11577            Clean up old runs
11578          </button>
11579          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11580            <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>
11581            Export Excel
11582          </button>
11583          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11584            <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>
11585            Export PNG
11586          </button>
11587          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11588            <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>
11589            Export PDF
11590          </button>
11591        </div>
11592      </div>
11593
11594      <div class="controls-centered">
11595        <label>Project Root:
11596          <select class="chart-select" id="root-sel">
11597            <option value="">All projects</option>
11598          </select>
11599        </label>
11600        <label>Y Metric:
11601          <select class="chart-select" id="y-sel">
11602            <option value="code_lines">Code Lines</option>
11603            <option value="comment_lines">Comment Lines</option>
11604            <option value="blank_lines">Blank Lines</option>
11605            <option value="physical_lines">Physical Lines</option>
11606            <option value="files_analyzed">Files Analyzed</option>
11607          </select>
11608        </label>
11609        <label>X Axis:
11610          <select class="chart-select" id="x-sel">
11611            <option value="time">By Time</option>
11612            <option value="commit" selected>By Commit</option>
11613            <option value="release">By Release</option>
11614            <option value="tag">Tagged Commits</option>
11615          </select>
11616        </label>
11617        <label id="submodule-label" style="display:none;">Submodule:
11618          <select class="chart-select" id="sub-sel">
11619            <option value="">All (project total)</option>
11620          </select>
11621        </label>
11622        <label>Chart Size:
11623          <select class="chart-select" id="scale-sel">
11624            <option value="0.75">Compact</option>
11625            <option value="1.2" selected>Normal</option>
11626            <option value="1.38">Large</option>
11627          </select>
11628        </label>
11629        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
11630      </div>
11631
11632      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
11633      <div id="data-table-wrap" style="overflow-x:auto;"></div>
11634    </div>
11635  </div>
11636
11637  <script nonce="{nonce}">
11638    (function() {{
11639      // Theme persistence
11640      var b = document.body;
11641      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11642      var tgl = document.getElementById('theme-toggle');
11643      if (tgl) tgl.addEventListener('click', function() {{
11644        var d = b.classList.toggle('dark-theme');
11645        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11646      }});
11647
11648      // Watermark randomizer
11649      (function() {{
11650        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11651        if (!wms.length) return;
11652        var placed = [];
11653        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;}}
11654        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];}}
11655        var half=Math.floor(wms.length/2);
11656        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;}});
11657      }})();
11658
11659      // Code particles
11660      (function() {{
11661        var container = document.getElementById('code-particles');
11662        if (!container) return;
11663        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'];
11664        for (var i = 0; i < 38; i++) {{
11665          (function(idx) {{
11666            var el = document.createElement('span');
11667            el.className = 'code-particle';
11668            el.textContent = snippets[idx % snippets.length];
11669            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11670            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11671            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11672            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';
11673            container.appendChild(el);
11674          }})(i);
11675        }}
11676      }})();
11677
11678      // Watched folder picker
11679      (function() {{
11680        var btn = document.getElementById('add-watched-btn');
11681        if (!btn) return;
11682        btn.addEventListener('click', function() {{
11683          fetch('/pick-directory?kind=reports')
11684            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11685            .then(function(data) {{
11686              if (!data.cancelled && data.selected_path) {{
11687                var form = document.createElement('form');
11688                form.method = 'POST';
11689                form.action = '/watched-dirs/add';
11690                var ri = document.createElement('input');
11691                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11692                var fi = document.createElement('input');
11693                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11694                form.appendChild(ri); form.appendChild(fi);
11695                document.body.appendChild(form);
11696                form.submit();
11697              }}
11698            }})
11699            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11700        }});
11701      }})();
11702
11703      // Settings / color-scheme modal
11704      (function() {{
11705        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'}}];
11706        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);}});}}
11707        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11708        var btn=document.getElementById('settings-btn');if(!btn)return;
11709        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11710        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>';
11711        document.body.appendChild(m);
11712        var g=document.getElementById('scheme-grid');
11713        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);}});
11714        var cl=document.getElementById('settings-close');
11715        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);
11716        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');}});
11717        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11718        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11719      }})();
11720    }})();
11721
11722    var ROOTS = {roots_json};
11723    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11724    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11725    var allData = [];
11726
11727    // Populate root selector
11728    var rootSel = document.getElementById('root-sel');
11729    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11730
11731    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();}}
11732    function fmtFull(n){{return Number(n).toLocaleString();}}
11733    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
11734
11735    // Tooltip
11736    var tt = document.createElement('div');
11737    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);';
11738    document.body.appendChild(tt);
11739    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11740    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';}}
11741    function hideTT(){{tt.style.display='none';}}
11742    window.addEventListener('blur',function(){{hideTT();}});
11743    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11744
11745    function statExact(compact, full){{
11746      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11747    }}
11748    function statVal(n){{
11749      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11750    }}
11751
11752    function updateStats(data){{
11753      var statsEl=document.getElementById('trend-stats');
11754      if(!statsEl)return;
11755      if(!data||!data.length){{statsEl.innerHTML='';return;}}
11756      var yKey=document.getElementById('y-sel').value;
11757      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11758      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11759      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11760      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11761      var absDelta=Math.abs(delta);
11762      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11763      var deltaExact=statExact(deltaCompact,deltaFull);
11764      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11765      statsEl.innerHTML=
11766        '<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>'+
11767        '<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>'+
11768        '<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>'+
11769        '<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>';
11770    }}
11771
11772    var subSel = document.getElementById('sub-sel');
11773    var subLabel = document.getElementById('submodule-label');
11774
11775    function populateSubmodules(root){{
11776      if(!subSel||!subLabel)return;
11777      while(subSel.options.length>1)subSel.remove(1);
11778      subSel.value='';
11779      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11780      fetch(url)
11781        .then(function(r){{return r.json();}})
11782        .then(function(subs){{
11783          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11784          subs.forEach(function(s){{
11785            var o=document.createElement('option');
11786            o.value=s.name;
11787            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11788            subSel.appendChild(o);
11789          }});
11790          subLabel.style.display='';
11791        }})
11792        .catch(function(){{subLabel.style.display='none';}});
11793    }}
11794
11795    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11796
11797    function loadAndRender(){{
11798      var root = rootSel.value;
11799      var sub = subSel ? subSel.value : '';
11800      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11801      document.getElementById('data-table-wrap').innerHTML='';
11802      var url = '/api/metrics/history?limit=100'
11803        + (root ? '&root='+encodeURIComponent(root) : '')
11804        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
11805      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11806        allData = data;
11807        render(data);
11808        updateStats(data);
11809      }}).catch(function(){{
11810        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>';
11811      }});
11812    }}
11813
11814    function render(data){{
11815      var yKey = document.getElementById('y-sel').value;
11816      var xMode = document.getElementById('x-sel').value;
11817
11818      // Filter for tag/release mode
11819      var pts = data;
11820      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11821
11822      // Sort oldest-first for the line chart
11823      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11824
11825      var wrap = document.getElementById('chart-wrap');
11826      if(!pts.length){{
11827        var emptyMsg = (xMode === 'tag')
11828          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11829          : 'No scan data found for the selected filters.';
11830        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11831        renderTable([]);
11832        return;
11833      }}
11834
11835      var scaleEl=document.getElementById('scale-sel');
11836      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
11837      renderTrendInto(wrap, pts, yKey, xMode, sc);
11838      renderTable(pts, yKey);
11839    }}
11840
11841    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
11842    // Shared by the inline chart and the Full View modal so both render identically.
11843    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
11844      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
11845      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
11846      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
11847      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;
11848      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
11849
11850      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11851
11852      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">';
11853      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>';
11854
11855      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
11856
11857      // Grid + Y axis ticks
11858      for(var ti=0;ti<=5;ti++){{
11859        var gy=PT+CH-Math.round(ti/5*CH);
11860        var gv=Math.round(ti/5*maxY);
11861        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
11862        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
11863      }}
11864
11865      // X axis labels (every N-th point to avoid crowding)
11866      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
11867      pts.forEach(function(d,i){{
11868        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11869        if(i%labelEvery===0||i===pts.length-1){{
11870          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)));
11871          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>';
11872        }}
11873      }});
11874
11875      // Axis label
11876      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
11877      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>';
11878      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>';
11879
11880      // Area fill + line path
11881      var pathD='';
11882      pts.forEach(function(d,i){{
11883        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11884        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11885        pathD+=(i===0?'M':'L')+x+','+y;
11886      }});
11887      if(pts.length>1){{
11888        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
11889        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
11890      }}
11891      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
11892
11893      // Data points (clickable) + permanent value labels
11894      var showLabels = pts.length <= 40;
11895      var labelEveryN = pts.length > 20 ? 2 : 1;
11896      pts.forEach(function(d,i){{
11897        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11898        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11899        var hasTags=d.tags&&d.tags.length>0;
11900        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
11901        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
11902        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+'"/>';
11903        if(showLabels && i%labelEveryN===0){{
11904          var lx=x, ly=y-r-5;
11905          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>';
11906        }}
11907      }});
11908
11909      svg+='</svg>';
11910      wrap.innerHTML=svg;
11911
11912      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
11913      function lineYAt(mx){{
11914        var n=pts.length;
11915        if(n===0)return PT+CH;
11916        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
11917        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
11918        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
11919        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
11920        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
11921        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
11922        return y0+t*(y1-y0);
11923      }}
11924
11925      // SVG-level mousemove: show the value tooltip only when the pointer is over the
11926      // gradient fill (inside the chart and at/below the line) — never in the empty
11927      // space above the line. Cursor follows the same rule.
11928      (function(){{
11929        var svgEl=wrap.querySelector('svg');
11930        if(!svgEl)return;
11931        svgEl.addEventListener('mousemove',function(e){{
11932          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
11933          var rect=svgEl.getBoundingClientRect();
11934          var scaleX=W/Math.max(rect.width,1);
11935          var scaleY=H/Math.max(rect.height,1);
11936          var mouseX=(e.clientX-rect.left)*scaleX;
11937          var mouseY=(e.clientY-rect.top)*scaleY;
11938          var ly=lineYAt(mouseX);
11939          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
11940          svgEl.style.cursor='pointer';
11941          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
11942          var d=pts[idx];
11943          var val=Number(d[yKey]);
11944          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
11945          showTT(e,
11946            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
11947            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
11948            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
11949          );
11950        }});
11951        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
11952      }})();
11953
11954      // Attach point tooltips
11955      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
11956        c.addEventListener('mouseover',function(e){{
11957          var d=pts[parseInt(this.dataset.idx)];
11958          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(''):'';
11959          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>':'';
11960          showTT(e,
11961            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
11962            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
11963            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
11964            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
11965          );
11966          this.setAttribute('r','8');
11967        }});
11968        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
11969        c.addEventListener('mousemove',moveTT);
11970        c.addEventListener('click',function(){{
11971          var d=pts[parseInt(this.dataset.idx)];
11972          if(d.html_url) window.open(d.html_url,'_blank');
11973        }});
11974      }});
11975    }}
11976
11977    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
11978    var shProjFilter='', shBranchFilter='';
11979
11980    function fmtPST(isoStr){{
11981      if(!isoStr)return'';
11982      var d=new Date(isoStr);
11983      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
11984      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);}}
11985      function p(n){{return n<10?'0'+n:String(n);}}
11986      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++;}}}}
11987      var yr=d.getUTCFullYear();
11988      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
11989      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
11990      var isDST=d>=dstStart&&d<dstEnd;
11991      var off=isDST?-7*3600*1000:-8*3600*1000;
11992      var lbl=isDST?'PDT':'PST';
11993      var loc=new Date(d.getTime()+off);
11994      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
11995    }}
11996
11997    function getShRows(){{
11998      var proj=shProjFilter.toLowerCase().trim();
11999      var branch=shBranchFilter;
12000      return shData.filter(function(d){{
12001        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12002        if(branch&&(d.branch||'')!==branch)return false;
12003        return true;
12004      }});
12005    }}
12006
12007    function renderShPage(){{
12008      var filtered=getShRows();
12009      if(shSortCol){{
12010        filtered.sort(function(a,b){{
12011          var va,vb;
12012          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12013          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12014          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12015          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12016          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12017          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12018        }});
12019      }}
12020      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12021      shPage=Math.min(shPage,totalPages);
12022      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12023      var visible=filtered.slice(start,end);
12024      var tbody=document.getElementById('sh-tbody');
12025      if(!tbody)return;
12026      tbody.innerHTML=visible.map(function(d){{
12027        var tsHtml=esc(fmtPST(d.timestamp));
12028        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>';
12029        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>';
12030        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12031        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12032        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12033        var reportCell='';
12034        if(d.html_url){{
12035          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12036          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>';}}
12037          reportCell+='</div>';
12038        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12039        if(d.submodule_links&&d.submodule_links.length){{
12040          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12041          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12042          reportCell+='</div></details>';
12043        }}
12044        return '<tr>'
12045          +'<td>'+tsHtml+'</td>'
12046          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12047          +'<td>'+runIdHtml+'</td>'
12048          +'<td>'+commitHtml+'</td>'
12049          +'<td>'+branchHtml+'</td>'
12050          +'<td>'+tags+'</td>'
12051          +'<td class="num">'+metricHtml+'</td>'
12052          +'<td class="report-cell">'+reportCell+'</td>'
12053          +'</tr>';
12054      }}).join('');
12055      var pgRange=document.getElementById('sh-pg-range');
12056      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12057      var pgInfo=document.getElementById('sh-pg-info');
12058      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12059      var pgBtns=document.getElementById('sh-pg-btns');
12060      if(pgBtns){{
12061        pgBtns.innerHTML='';
12062        function mkPgBtn(lbl,pg,active,disabled){{
12063          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12064          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12065          return b;
12066        }}
12067        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12068        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12069        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12070        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12071      }}
12072    }}
12073
12074    function wireTableBehavior(){{
12075      var pf=document.getElementById('sh-proj-filter');
12076      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12077      var bf=document.getElementById('sh-branch-filter');
12078      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12079      var rb=document.getElementById('sh-reset-btn');
12080      if(rb)rb.addEventListener('click',function(){{
12081        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12082        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12083        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12084        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');}});
12085        renderShPage();
12086      }});
12087      var pps=document.getElementById('sh-per-page');
12088      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12089      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12090      ths.forEach(function(th){{
12091        th.addEventListener('click',function(e){{
12092          if(e.target.classList.contains('col-resize-handle'))return;
12093          var col=th.dataset.col;
12094          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12095          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12096          th.classList.add('sort-'+shSortOrder);
12097          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12098          shPage=1;renderShPage();
12099        }});
12100      }});
12101      var table=document.getElementById('scan-history-table');
12102      if(!table)return;
12103      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12104      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12105      allThs.forEach(function(th,i){{
12106        var handle=th.querySelector('.col-resize-handle');
12107        if(!handle||!cols[i])return;
12108        var startX,startW;
12109        handle.addEventListener('mousedown',function(e){{
12110          e.stopPropagation();e.preventDefault();
12111          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12112          handle.classList.add('dragging');
12113          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12114          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12115          document.addEventListener('mousemove',onMove);
12116          document.addEventListener('mouseup',onUp);
12117        }});
12118      }});
12119    }}
12120
12121    function renderTable(pts, yKey){{
12122      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12123      var wrap=document.getElementById('data-table-wrap');
12124      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12125      var yLabel=Y_LABELS[yKey]||yKey||'';
12126      shData=pts.slice().reverse();
12127      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12128      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12129      var branches={{}};
12130      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12131      var branchOpts='<option value="">All branches</option>';
12132      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12133      wrap.innerHTML=
12134        '<div class="chart-section-header">SCAN HISTORY</div>'+
12135        '<div class="filter-row">'+
12136          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12137          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12138          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12139        '</div>'+
12140        '<div class="table-wrap">'+
12141        '<table id="scan-history-table" class="data-table">'+
12142        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12143        '<thead><tr id="sh-thead">'+
12144        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12145        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12146        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12147        '<th>Commit<div class="col-resize-handle"></div></th>'+
12148        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12149        '<th>Tags<div class="col-resize-handle"></div></th>'+
12150        '<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>'+
12151        '<th>Report<div class="col-resize-handle"></div></th>'+
12152        '</tr></thead>'+
12153        '<tbody id="sh-tbody"></tbody>'+
12154        '</table>'+
12155        '</div>'+
12156        '<div class="pagination">'+
12157          '<span class="pagination-info" id="sh-pg-info"></span>'+
12158          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12159          '<div style="display:flex;align-items:center;gap:8px;">'+
12160            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12161            '<select class="filter-select" id="sh-per-page">'+
12162              '<option value="10">10 per page</option>'+
12163              '<option value="25" selected>25 per page</option>'+
12164              '<option value="50">50 per page</option>'+
12165              '<option value="100">100 per page</option>'+
12166            '</select>'+
12167            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12168          '</div>'+
12169        '</div>';
12170      wireTableBehavior();
12171      renderShPage();
12172    }}
12173
12174    function exportXLSX(){{
12175      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12176      var xbtn=document.getElementById('export-xlsx-btn');
12177      var xorig=xbtn?xbtn.innerHTML:'';
12178      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12179      var root=rootSel.value;
12180      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12181      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12182        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12183        buildAndDownloadXLSX(cm);
12184      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12185    }}
12186
12187    function buildAndDownloadXLSX(churnMap){{
12188      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12189      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12190      // (sorted is newest-first), so a given project/commit appears at most once.
12191      var seenPC={{}},dedup=[];
12192      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12193      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12194      var s1R=dedup.map(function(d){{
12195        var c=churnMap[d.run_id]||{{}};
12196        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];
12197      }});
12198      var pm={{}};
12199      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12200      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'];
12201      var s2R=Object.keys(pm).map(function(p){{
12202        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12203        var lat=sc[sc.length-1],fst=sc[0];
12204        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12205        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);
12206        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];
12207      }});
12208      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12209      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12210      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12211      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12212    }}
12213
12214    function buildXLSX(sheets,chartRows,chartRows2){{
12215      function s2b(s){{return new TextEncoder().encode(s);}}
12216      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12217      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;}}
12218      function crc32(d){{
12219        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;}}}}
12220        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12221      }}
12222      function buildSheet(hdr,rows,drawRid,withCtrl){{
12223        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12224        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12225        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12226        x+='<row r="1">';
12227        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12228        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12229        x+='</row>';
12230        rows.forEach(function(row,ri){{
12231          var rn=ri+2;
12232          x+='<row r="'+rn+'">';
12233          row.forEach(function(cell,ci){{
12234            var addr=col2l(ci+1)+rn;
12235            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12236            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12237          }});
12238          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>";}}
12239          x+='</row>';
12240        }});
12241        x+='</sheetData>';
12242        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12243        return x+'</worksheet>';
12244      }}
12245      function buildChartXML(rows){{
12246        var sn="'Scan History'";
12247        var nr=rows.length,er=nr+1;
12248        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'}}];
12249        var catCol='C',catIdx=2;
12250        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12251        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">';
12252        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12253        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>';
12254        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12255        sd.forEach(function(s,i){{
12256          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12257          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>';
12258          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12259          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>';
12260          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12261          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12262          x+='</c:strCache></c:strRef></c:cat>';
12263          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+'"/>';
12264          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12265          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12266        }});
12267        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12268        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>';
12269        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>';
12270        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12271        return x;
12272      }}
12273      function buildChartXML2(rows){{
12274        var sn="'By Project'";
12275        var nr=rows.length,er=nr+1;
12276        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'}}];
12277        var catCol='A',catIdx=0;
12278        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12279        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">';
12280        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12281        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>';
12282        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12283        sd.forEach(function(s,i){{
12284          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12285          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>';
12286          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12287          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>';
12288          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12289          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12290          x+='</c:strCache></c:strRef></c:cat>';
12291          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+'"/>';
12292          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12293          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12294        }});
12295        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12296        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>';
12297        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>';
12298        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12299        return x;
12300      }}
12301      function buildChartXML3(rows){{
12302        var sn="'Scan History'";
12303        var nr=rows.length,er=nr+1;
12304        var catCol='C',catIdx=2;
12305        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12306        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">';
12307        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12308        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12309        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12310        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>";
12311        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12312        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>';
12313        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>';
12314        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12315        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12316        x+='</c:strCache></c:strRef></c:cat>';
12317        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+'"/>';
12318        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12319        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12320        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12321        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>';
12322        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>';
12323        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>';
12324        return x;
12325      }}
12326      function buildFocusSheet(drawRid){{
12327        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12328        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12329        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12330        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12331        x+='<sheetData><row r="1">';
12332        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12333        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12334        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12335        x+='</row></sheetData>';
12336        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>';
12337        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12338        return x+'</worksheet>';
12339      }}
12340      var hasChart=!!(chartRows&&chartRows.length);
12341      var nr=hasChart?chartRows.length:0;
12342      var hasChart2=!!(chartRows2&&chartRows2.length);
12343      var nr2=hasChart2?chartRows2.length:0;
12344      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>';
12345      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"/>';
12346      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12347      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"/>';}}
12348      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"/>';}}
12349      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12350      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>';
12351      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12352      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"/>';}});
12353      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12354      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>';
12355      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12356      wbx+='</sheets></workbook>';
12357      var files=[
12358        {{name:'[Content_Types].xml',data:s2b(ct)}},
12359        {{name:'_rels/.rels',data:s2b(dotrels)}},
12360        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12361        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12362        {{name:'xl/styles.xml',data:s2b(styl)}}
12363      ];
12364      // Chart embedded directly in Scan History (sheet1); By Project is plain
12365      sheets.forEach(function(s,i){{
12366        var sx;
12367        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12368        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12369        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12370      }});
12371      if(hasChart){{
12372        var fromRow=nr+4,toRow=nr+34;
12373        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>')}});
12374        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12375        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">';
12376        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12377        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>';
12378        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>';
12379        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12380        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12381        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12382        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12383        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12384        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12385        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>')}});
12386        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12387        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>')}});
12388        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12389        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">';
12390        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12391        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>';
12392        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>';
12393        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12394        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12395        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12396        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12397        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12398        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12399        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>')}});
12400        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12401      }}
12402      if(hasChart2){{
12403        var fromRow2=nr2+4,toRow2=nr2+36;
12404        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>')}});
12405        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12406        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">';
12407        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12408        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>';
12409        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>';
12410        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12411        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12412        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12413        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12414        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12415        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12416        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>')}});
12417        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12418      }}
12419      var parts=[],offsets=[],total=0;
12420      files.forEach(function(f){{
12421        offsets.push(total);
12422        var nb=s2b(f.name),crc=crc32(f.data);
12423        var h=new DataView(new ArrayBuffer(30+nb.length));
12424        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12425        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12426        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12427        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12428        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12429        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12430        total+=30+nb.length+f.data.length;
12431      }});
12432      var cdStart=total;
12433      files.forEach(function(f,fi){{
12434        var nb=s2b(f.name),crc=crc32(f.data);
12435        var cd=new DataView(new ArrayBuffer(46+nb.length));
12436        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12437        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12438        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12439        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12440        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12441        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12442        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12443      }});
12444      var cdSz=total-cdStart;
12445      var eocd=new DataView(new ArrayBuffer(22));
12446      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12447      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12448      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12449      parts.push(new Uint8Array(eocd.buffer));
12450      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12451      var out=new Uint8Array(sz);var off=0;
12452      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12453      return out.buffer;
12454    }}
12455
12456    function trendTitleParts(){{
12457      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12458      var subSelEl=document.getElementById('sub-sel');
12459      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12460      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12461      var proj=(document.getElementById('root-sel').value)||'All projects';
12462      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12463      var cnt=(allData&&allData.length)||0;
12464      var now=new Date();
12465      function p2(n){{return(n<10?'0':'')+n;}}
12466      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12467      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12468    }}
12469
12470    function exportPNG(){{
12471      var svgEl=document.querySelector('#chart-wrap svg');
12472      if(!svgEl){{alert('No chart to export yet.');return;}}
12473      var svgStr=new XMLSerializer().serializeToString(svgEl);
12474      var vb=svgEl.viewBox.baseVal,scale=2;
12475      var headerH=84,footerH=36;
12476      var lw=(vb.width||900),lh=(vb.height||380);
12477      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12478      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12479      var url=URL.createObjectURL(blob);
12480      var img=new Image();
12481      var tp=trendTitleParts();
12482      img.onload=function(){{
12483        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12484        var ctx=canvas.getContext('2d');
12485        var cs=getComputedStyle(document.body);
12486        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12487        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12488        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12489        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12490        ctx.scale(scale,scale);
12491        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12492        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12493        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12494        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12495        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;
12496        ctx.drawImage(img,0,headerH);
12497        var fy=headerH+lh;
12498        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;
12499        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12500        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);
12501        ctx.textAlign='left';
12502        URL.revokeObjectURL(url);
12503        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12504      }};
12505      img.src=url;
12506    }}
12507
12508    function exportPDF(){{
12509      var svgEl=document.querySelector('#chart-wrap svg');
12510      if(!svgEl){{alert('No chart to export yet.');return;}}
12511      var tp=trendTitleParts();
12512      var svgStr=new XMLSerializer().serializeToString(svgEl);
12513      var statsEl=document.getElementById('trend-stats');
12514      var statsHtml=statsEl?statsEl.innerHTML:'';
12515      var yK=document.getElementById('y-sel').value;
12516      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12517      var yL=yLabels[yK]||yK;
12518      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12519      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>';
12520      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>';}});
12521      tableHtml+='</tbody></table>';
12522      var css='<style>'
12523        +'*{{box-sizing:border-box;}}'
12524        +'html,body{{margin:0;padding:0;}}'
12525        // Masthead/footer flow in document order — a position:fixed header repeats
12526        // on every printed page in Chromium and hides the rows beneath it on pages
12527        // 2+. The trend table's <thead> repeats per page natively instead.
12528        +'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;}}'
12529        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12530        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12531        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12532        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12533        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12534        +'.rep-body{{padding:22px 34px 0;}}'
12535        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12536        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12537        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12538        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12539        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12540        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12541        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12542        +'.stat-chip-tip{{display:none!important;}}'
12543        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12544        +'.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;}}'
12545        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12546        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12547        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12548        +'.rep-chart svg{{max-width:100%;height:auto;}}'
12549        +'.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;}}'
12550        +'.filter-row{{display:none!important;}}'
12551        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12552        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12553        +'th{{background:#f0e9e0;font-weight:800;}}'
12554        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12555        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12556        +'.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;}}'
12557        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12558        +'</style>';
12559      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12560        +'<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>'
12561        +'<div class="rep-body">'
12562        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12563        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12564        +'<div class="summary-strip">'+statsHtml+'</div>'
12565        +'<div class="rep-chart">'+svgStr+'</div>'
12566        +tableHtml
12567        +'</div>'
12568        +'<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>'
12569        +'</body></html>';
12570      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12571    }}
12572
12573    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12574      var el=document.getElementById(id);
12575      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12576    }});
12577    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12578    // tracks the container like the responsive Chart.js charts do.
12579    var _rsT=null;
12580    window.addEventListener('resize',function(){{
12581      if(_rsT)clearTimeout(_rsT);
12582      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12583    }});
12584    rootSel.addEventListener('change',function(){{
12585      populateSubmodules(rootSel.value);
12586      loadAndRender();
12587    }});
12588    if(subSel)subSel.addEventListener('change',loadAndRender);
12589
12590    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12591    (function(){{
12592      var fvBtn=document.getElementById('tr-chart-fv-btn');
12593      if(!fvBtn)return;
12594      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12595      fvBtn.addEventListener('click',function(){{
12596        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12597        var yKey=document.getElementById('y-sel').value;
12598        var xMode=document.getElementById('x-sel').value;
12599        var pts=allData;
12600        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12601        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12602        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12603        var tp=trendTitleParts();
12604        var ov=document.createElement('div');
12605        ov.className='tr-chart-full-modal';
12606        ov.innerHTML='<div class="tr-chart-full-inner">'
12607          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12608          +'<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>'
12609          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12610          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12611          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12612        document.body.appendChild(ov);
12613        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12614        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12615        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12616        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12617        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12618      }});
12619    }})();
12620
12621    var xlsxBtn=document.getElementById('export-xlsx-btn');
12622    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12623    var pngBtn=document.getElementById('export-png-btn');
12624    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12625    var pdfBtn=document.getElementById('export-pdf-btn');
12626    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12627
12628    // ── Clean-up modal ───────────────────────────────────────────────────────
12629    (function(){{
12630      var triggerBtn=document.getElementById('cleanup-runs-btn');
12631      if(!triggerBtn)return;
12632      var modal=document.createElement('div');
12633      modal.className='tr-modal-backdrop';
12634      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12635        +'<div class="tr-modal-head">'
12636        +'<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>'
12637        +'<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>'
12638        +'</div>'
12639        +'<div class="tr-modal-body">'
12640        +'<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>'
12641        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12642        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12643        +'<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;">'
12644        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12645        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12646        +'</div>'
12647        +'<div class="tr-modal-foot">'
12648        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12649        +'<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>'
12650        +'</div></div>';
12651      document.body.appendChild(modal);
12652      triggerBtn.addEventListener('click',function(){{
12653        document.getElementById('cleanup-status').style.display='none';
12654        modal.style.display='flex';
12655      }});
12656      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12657      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12658      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12659        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12660        var confirmBtn=this;
12661        confirmBtn.disabled=true;
12662        var status=document.getElementById('cleanup-status');
12663        status.style.display='block';
12664        status.style.background='#dbeafe';status.style.color='#1e40af';
12665        status.textContent='Deleting\u2026';
12666        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12667        .then(function(resp){{
12668          return resp.json().then(function(d){{
12669            if(resp.ok){{
12670              status.style.background='#dcfce7';status.style.color='#166534';
12671              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12672              setTimeout(function(){{window.location.reload();}},1500);
12673            }}else{{
12674              status.style.background='#fee2e2';status.style.color='#991b1b';
12675              status.textContent='Error: '+(d.error||'Unexpected error');
12676              confirmBtn.disabled=false;
12677            }}
12678          }});
12679        }})
12680        .catch(function(e){{
12681          status.style.background='#fee2e2';status.style.color='#991b1b';
12682          status.textContent='Network error: '+String(e);
12683          confirmBtn.disabled=false;
12684        }});
12685      }});
12686    }})();
12687
12688    // ── Retention policy panel ────────────────────────────────────────────────
12689    (function(){{
12690      var triggerBtn=document.getElementById('retention-policy-btn');
12691      if(!triggerBtn)return;
12692      var modal=document.createElement('div');
12693      modal.className='tr-modal-backdrop';
12694      modal.style.zIndex='9001';
12695      modal.innerHTML=''
12696        +'<div class="tr-modal" style="max-width:640px;">'
12697        +'<div class="tr-modal-head">'
12698        +'<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>'
12699        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12700        +'</div>'
12701        +'<div class="tr-modal-body">'
12702        +'<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>'
12703        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12704        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12705        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12706        +'</div>'
12707        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12708        +'<div>'
12709        +'<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>'
12710        +'<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;">'
12711        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12712        +'</div>'
12713        +'<div>'
12714        +'<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>'
12715        +'<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;">'
12716        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12717        +'</div>'
12718        +'</div>'
12719        +'<div style="margin-bottom:20px;">'
12720        +'<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>'
12721        +'<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;">'
12722        +'<option value="1">Every hour</option>'
12723        +'<option value="6">Every 6 hours</option>'
12724        +'<option value="12">Every 12 hours</option>'
12725        +'<option value="24" selected>Every 24 hours</option>'
12726        +'<option value="48">Every 2 days</option>'
12727        +'<option value="72">Every 3 days</option>'
12728        +'<option value="168">Every week</option>'
12729        +'</select>'
12730        +'</div>'
12731        +'<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>'
12732        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12733        +'</div>'
12734        +'<div class="tr-modal-foot">'
12735        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12736        +'<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>'
12737        +'<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>'
12738        +'</div>'
12739        +'</div>';
12740      document.body.appendChild(modal);
12741
12742      function rpShowStatus(msg,ok){{
12743        var s=document.getElementById('rp-status');
12744        s.style.display='block';
12745        s.style.background=ok?'#dcfce7':'#fee2e2';
12746        s.style.color=ok?'#166534':'#991b1b';
12747        s.textContent=msg;
12748      }}
12749      function fmtAgo(iso){{
12750        if(!iso)return'Never';
12751        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12752        if(diff<60)return diff+'s ago';
12753        if(diff<3600)return Math.floor(diff/60)+'m ago';
12754        if(diff<86400)return Math.floor(diff/3600)+'h ago';
12755        return Math.floor(diff/86400)+'d ago';
12756      }}
12757      function loadPolicy(){{
12758        fetch('/api/cleanup-policy')
12759          .then(function(r){{return r.json();}})
12760          .then(function(d){{
12761            var p=d.policy;
12762            document.getElementById('rp-enabled').checked=p?p.enabled:false;
12763            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12764            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12765            var sel=document.getElementById('rp-interval');
12766            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;}}}}}}
12767            var lr=document.getElementById('rp-last-run');
12768            if(d.last_run_at){{
12769              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'):'');
12770            }}else{{
12771              lr.textContent='Auto-cleanup has not run yet.';
12772            }}
12773          }})
12774          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12775      }}
12776
12777      triggerBtn.addEventListener('click',function(){{
12778        document.getElementById('rp-status').style.display='none';
12779        loadPolicy();
12780        modal.style.display='flex';
12781      }});
12782      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12783      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12784
12785      document.getElementById('rp-save-btn').addEventListener('click',function(){{
12786        var enabled=document.getElementById('rp-enabled').checked;
12787        var ageVal=document.getElementById('rp-max-age').value.trim();
12788        var countVal=document.getElementById('rp-max-count').value.trim();
12789        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12790        if(enabled&&!ageVal&&!countVal){{
12791          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12792          return;
12793        }}
12794        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12795        var saveBtn=document.getElementById('rp-save-btn');
12796        saveBtn.disabled=true;
12797        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12798          .then(function(r){{
12799            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12800            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12801          }})
12802          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12803          .finally(function(){{saveBtn.disabled=false;}});
12804      }});
12805
12806      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12807        var btn=this;
12808        var orig=btn.innerHTML;
12809        btn.disabled=true;
12810        btn.textContent='Running\u2026';
12811        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12812          .then(function(r){{return r.json();}})
12813          .then(function(d){{
12814            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12815            loadPolicy();
12816          }})
12817          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12818          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12819      }});
12820    }})();
12821
12822    populateSubmodules(rootSel.value);
12823    loadAndRender();
12824
12825    (function randomizeWatermarks() {{
12826      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12827      if (!wms.length) return;
12828      var placed = [];
12829      function tooClose(top, left) {{
12830        for (var i = 0; i < placed.length; i++) {{
12831          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12832          if (dt < 16 && dl < 12) return true;
12833        }}
12834        return false;
12835      }}
12836      function pick(leftBand) {{
12837        for (var attempt = 0; attempt < 50; attempt++) {{
12838          var top = Math.random() * 88 + 2;
12839          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12840          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
12841        }}
12842        var top = Math.random() * 88 + 2;
12843        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12844        placed.push([top, left]); return [top, left];
12845      }}
12846      var half = Math.floor(wms.length / 2);
12847      wms.forEach(function (img, i) {{
12848        var pos = pick(i < half);
12849        var size = Math.floor(Math.random() * 100 + 120);
12850        var rot = (Math.random() * 360).toFixed(1);
12851        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12852        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;
12853      }});
12854    }})();
12855    (function spawnCodeParticles() {{
12856      var container = document.getElementById('code-particles');
12857      if (!container) return;
12858      var snippets = [
12859        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12860        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12861        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12862        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12863        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
12864      ];
12865      var count = 38;
12866      for (var i = 0; i < count; i++) {{
12867        (function(idx) {{
12868          var el = document.createElement('span');
12869          el.className = 'code-particle';
12870          el.textContent = snippets[idx % snippets.length];
12871          var left = Math.random() * 94 + 2;
12872          var top = Math.random() * 88 + 6;
12873          var dur = (Math.random() * 10 + 9).toFixed(1);
12874          var delay = (Math.random() * 18).toFixed(1);
12875          var rot = (Math.random() * 26 - 13).toFixed(1);
12876          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12877          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
12878          container.appendChild(el);
12879        }})(i);
12880      }}
12881    }})();
12882  </script>
12883  <footer class="site-footer">
12884    local code analysis - metrics, history and reports
12885    &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>
12886    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12887    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12888    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12889    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12890  </footer>
12891  <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>
12892  {toast_assets}
12893</body>
12894</html>"##,
12895    );
12896
12897    Html(html).into_response()
12898}
12899
12900fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12901    use std::collections::HashMap;
12902    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
12903        return vec![];
12904    }
12905    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
12906    for rec in per_file_records {
12907        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
12908            let e = totals.entry(lang.display_name().to_string()).or_default();
12909            e.0 += u64::from(cov.lines_found);
12910            e.1 += u64::from(cov.lines_hit);
12911        }
12912    }
12913    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
12914    let mut pairs: Vec<(String, f64)> = totals
12915        .into_iter()
12916        .filter(|(_, (found, _))| *found > 0)
12917        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
12918        .collect();
12919    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
12920    pairs
12921        .iter()
12922        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
12923        .collect()
12924}
12925
12926fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
12927    let mut high = 0u64;
12928    let mut mid = 0u64;
12929    let mut low = 0u64;
12930    for rec in per_file_records {
12931        if let Some(cov) = &rec.coverage {
12932            if cov.lines_found == 0 {
12933                continue;
12934            }
12935            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
12936            if pct >= 80.0 {
12937                high += 1;
12938            } else if pct >= 50.0 {
12939                mid += 1;
12940            } else {
12941                low += 1;
12942            }
12943        }
12944    }
12945    (high, mid, low)
12946}
12947
12948fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12949    let mut arr: Vec<serde_json::Value> = per_file_records
12950        .iter()
12951        .filter_map(|rec| {
12952            rec.coverage.as_ref().map(|cov| {
12953                let line_pct = if cov.lines_found > 0 {
12954                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
12955                        / 10.0
12956                } else {
12957                    0.0
12958                };
12959                let fn_pct = if cov.functions_found > 0 {
12960                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
12961                        .round()
12962                        / 10.0
12963                } else {
12964                    -1.0
12965                };
12966                serde_json::json!({
12967                    "rel": rec.relative_path,
12968                    "lang": rec.language.map_or("?", |l| l.display_name()),
12969                    "line_pct": line_pct,
12970                    "fn_pct": fn_pct,
12971                    "lhit": cov.lines_hit,
12972                    "lfound": cov.lines_found,
12973                    "fhit": cov.functions_hit,
12974                    "ffound": cov.functions_found,
12975                })
12976            })
12977        })
12978        .collect();
12979    arr.sort_by(|a, b| {
12980        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
12981        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
12982        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
12983    });
12984    arr
12985}
12986
12987#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
12988fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
12989    let mut langs: Vec<&sloc_core::LanguageSummary> = run
12990        .totals_by_language
12991        .iter()
12992        .filter(|l| l.test_count > 0)
12993        .collect();
12994    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
12995    let lang_tests: Vec<serde_json::Value> = langs
12996        .iter()
12997        .map(|l| {
12998            let d = if l.code_lines > 0 {
12999                l.test_count as f64 / l.code_lines as f64 * 1000.0
13000            } else {
13001                0.0
13002            };
13003            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13004                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13005                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13006        })
13007        .collect();
13008    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13009    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13010    let t = &run.summary_totals;
13011    let total_tests = t.test_count;
13012    let density = if t.code_lines > 0 {
13013        total_tests as f64 / t.code_lines as f64 * 1000.0
13014    } else {
13015        0.0
13016    };
13017    let most_tested = langs.first().map_or_else(
13018        || "\u{2014}".to_string(),
13019        |l| l.language.display_name().to_string(),
13020    );
13021    let test_files: u64 = run
13022        .per_file_records
13023        .iter()
13024        .filter(|f| f.raw_line_categories.test_count > 0)
13025        .count() as u64;
13026    let cov_line = if t.coverage_lines_found > 0 {
13027        format!(
13028            "{:.1}",
13029            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13030        )
13031    } else {
13032        "0".to_string()
13033    };
13034    let cov_fn = if t.coverage_functions_found > 0 {
13035        format!(
13036            "{:.1}",
13037            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13038        )
13039    } else {
13040        "0".to_string()
13041    };
13042    let cov_branch = if t.coverage_branches_found > 0 {
13043        format!(
13044            "{:.1}",
13045            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13046        )
13047    } else {
13048        "0".to_string()
13049    };
13050    let has_cov = !cov_arr.is_empty();
13051    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13052    serde_json::json!({
13053        "totals": {
13054            "test_count": total_tests,
13055            "assertions": t.test_assertion_count,
13056            "suites": t.test_suite_count,
13057            "test_files": test_files,
13058            "total_files": t.files_analyzed,
13059            "density_str": format!("{density:.1}"),
13060            "most_tested": most_tested,
13061            "langs_with_tests": langs.len(),
13062            "cov_line": cov_line,
13063            "cov_fn": cov_fn,
13064            "cov_branch": cov_branch,
13065        },
13066        "lang_tests": lang_tests,
13067        "cov": cov_arr,
13068        "cov_tiers": {"high": high, "mid": mid, "low": low},
13069        "file_cov": file_cov_arr,
13070        "has_coverage": has_cov,
13071        "submodules": {},
13072    })
13073}
13074
13075#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13076fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13077    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13078        .language_summaries
13079        .iter()
13080        .filter(|l| l.test_count > 0)
13081        .collect();
13082    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13083    let lang_tests: Vec<serde_json::Value> = langs
13084        .iter()
13085        .map(|l| {
13086            let d = if l.code_lines > 0 {
13087                l.test_count as f64 / l.code_lines as f64 * 1000.0
13088            } else {
13089                0.0
13090            };
13091            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13092                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13093                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13094        })
13095        .collect();
13096    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13097    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13098    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13099    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13100    let density = if sub.code_lines > 0 {
13101        total_tests as f64 / sub.code_lines as f64 * 1000.0
13102    } else {
13103        0.0
13104    };
13105    let most_tested = langs.first().map_or_else(
13106        || "\u{2014}".to_string(),
13107        |l| l.language.display_name().to_string(),
13108    );
13109    serde_json::json!({
13110        "totals": {
13111            "test_count": total_tests,
13112            "assertions": total_assertions,
13113            "suites": total_suites,
13114            "test_files": test_files_approx,
13115            "total_files": sub.files_analyzed,
13116            "density_str": format!("{density:.1}"),
13117            "most_tested": most_tested,
13118            "langs_with_tests": langs.len(),
13119            "cov_line": "0",
13120            "cov_fn": "0",
13121            "cov_branch": "0",
13122        },
13123        "lang_tests": lang_tests,
13124        "cov": [],
13125        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13126        "has_coverage": false,
13127    })
13128}
13129
13130fn compute_cov_json_str(run: &AnalysisRun) -> String {
13131    use std::collections::HashMap;
13132    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13133    for rec in &run.per_file_records {
13134        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13135            let e = totals.entry(lang.display_name().to_string()).or_default();
13136            e.0 += u64::from(cov.lines_found);
13137            e.1 += u64::from(cov.lines_hit);
13138        }
13139    }
13140    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13141    let mut pairs: Vec<(String, f64)> = totals
13142        .into_iter()
13143        .filter(|(_, (found, _))| *found > 0)
13144        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13145        .collect();
13146    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13147    let parts: Vec<String> = pairs
13148        .iter()
13149        .map(|(lang, pct)| {
13150            let name = lang.replace('"', "\\\"");
13151            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13152        })
13153        .collect();
13154    format!("[{}]", parts.join(","))
13155}
13156
13157fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13158    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13159    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13160}
13161
13162fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13163    let mut entry = build_test_scope_entry(run);
13164    if !run.submodule_summaries.is_empty() {
13165        let subs: serde_json::Map<String, serde_json::Value> = run
13166            .submodule_summaries
13167            .iter()
13168            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13169            .collect();
13170        entry["submodules"] = serde_json::Value::Object(subs);
13171    }
13172    entry
13173}
13174
13175fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13176    let name = l.language.display_name().replace('"', "\\\"");
13177    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13178    let density = if l.code_lines > 0 {
13179        l.test_count as f64 / l.code_lines as f64 * 1000.0
13180    } else {
13181        0.0
13182    };
13183    format!(
13184        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13185        name = name,
13186        t = l.test_count,
13187        a = l.test_assertion_count,
13188        s = l.test_suite_count,
13189        c = l.code_lines,
13190        d = density,
13191        f = l.files,
13192    )
13193}
13194
13195fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13196    let Some(r) = run else {
13197        return "[]".to_string();
13198    };
13199    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13200        .totals_by_language
13201        .iter()
13202        .filter(|l| l.test_count > 0)
13203        .collect();
13204    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13205    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13206    format!("[{}]", parts.join(","))
13207}
13208
13209/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13210async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13211    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13212    scope_map.insert(
13213        "__all__".to_string(),
13214        latest_run.map_or_else(
13215            || {
13216                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13217                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13218                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13219                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13220                    "has_coverage":false,"submodules":{}})
13221            },
13222            build_test_scope_entry,
13223        ),
13224    );
13225    let all_roots: Vec<String> = {
13226        let reg = state.registry.lock().await;
13227        let mut seen = std::collections::BTreeSet::new();
13228        reg.entries
13229            .iter()
13230            .flat_map(|e| e.input_roots.iter().cloned())
13231            .filter(|r| seen.insert(r.clone()))
13232            .collect()
13233    };
13234    for root in &all_roots {
13235        let json_path = {
13236            let reg = state.registry.lock().await;
13237            reg.entries
13238                .iter()
13239                .find(|e| e.input_roots.iter().any(|r| r == root))
13240                .and_then(|e| e.json_path.clone())
13241        };
13242        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13243            let json_str = tokio::fs::read_to_string(&p).await.ok();
13244            json_str
13245                .as_deref()
13246                .and_then(|s| serde_json::from_str(s).ok())
13247        } else {
13248            None
13249        };
13250        if let Some(ref run) = run_for_root {
13251            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13252        }
13253    }
13254    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13255}
13256
13257// GET /test-metrics
13258#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13259#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13260async fn test_metrics_handler(
13261    State(state): State<AppState>,
13262    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13263) -> Response {
13264    auto_scan_watched_dirs(&state).await;
13265    let watched_dirs_list: Vec<String> = {
13266        let wd = state.watched_dirs.lock().await;
13267        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13268    };
13269    let latest_run: Option<AnalysisRun> = {
13270        let json_path = {
13271            let reg = state.registry.lock().await;
13272            reg.entries.first().and_then(|e| e.json_path.clone())
13273        };
13274        if let Some(p) = json_path {
13275            let json_str = tokio::fs::read_to_string(&p).await.ok();
13276            json_str
13277                .as_deref()
13278                .and_then(|s| serde_json::from_str(s).ok())
13279        } else {
13280            None
13281        }
13282    };
13283
13284    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13285    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13286
13287    // Build coverage chart JSON (per-language avg line coverage %).
13288    let cov_json: String = latest_run
13289        .as_ref()
13290        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13291        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13292
13293    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13294    let _cov_tier_json: String = latest_run
13295        .as_ref()
13296        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13297        .map_or_else(
13298            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13299            compute_cov_tier_json_str,
13300        );
13301
13302    let total_tests: u64 = latest_run
13303        .as_ref()
13304        .map_or(0, |r| r.summary_totals.test_count);
13305    let total_assertions: u64 = latest_run
13306        .as_ref()
13307        .map_or(0, |r| r.summary_totals.test_assertion_count);
13308    let total_suites: u64 = latest_run
13309        .as_ref()
13310        .map_or(0, |r| r.summary_totals.test_suite_count);
13311    let total_code: u64 = latest_run
13312        .as_ref()
13313        .map_or(0, |r| r.summary_totals.code_lines);
13314    let workspace_density: f64 = if total_code > 0 {
13315        total_tests as f64 / total_code as f64 * 1000.0
13316    } else {
13317        0.0
13318    };
13319    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13320        r.totals_by_language
13321            .iter()
13322            .filter(|l| l.test_count > 0)
13323            .count()
13324    });
13325    let most_tested: String = latest_run
13326        .as_ref()
13327        .and_then(|r| {
13328            r.totals_by_language
13329                .iter()
13330                .filter(|l| l.test_count > 0)
13331                .max_by_key(|l| l.test_count)
13332        })
13333        .map_or_else(
13334            || "\u{2014}".to_string(),
13335            |l| l.language.display_name().to_string(),
13336        );
13337    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13338        r.per_file_records
13339            .iter()
13340            .filter(|f| f.raw_line_categories.test_count > 0)
13341            .count() as u64
13342    });
13343    let total_files_analyzed: u64 = latest_run
13344        .as_ref()
13345        .map_or(0, |r| r.summary_totals.files_analyzed);
13346    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13347
13348    // Aggregated coverage percentages from summary_totals
13349    let cov_line_pct_str: String = latest_run
13350        .as_ref()
13351        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13352        .map_or_else(
13353            || "0".to_string(),
13354            |r| {
13355                format!(
13356                    "{:.1}",
13357                    r.summary_totals.coverage_lines_hit as f64
13358                        / r.summary_totals.coverage_lines_found as f64
13359                        * 100.0
13360                )
13361            },
13362        );
13363    let cov_fn_pct_str: String = latest_run
13364        .as_ref()
13365        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13366        .map_or_else(
13367            || "0".to_string(),
13368            |r| {
13369                format!(
13370                    "{:.1}",
13371                    r.summary_totals.coverage_functions_hit as f64
13372                        / r.summary_totals.coverage_functions_found as f64
13373                        * 100.0
13374                )
13375            },
13376        );
13377    let cov_branch_pct_str: String = latest_run
13378        .as_ref()
13379        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13380        .map_or_else(
13381            || "0".to_string(),
13382            |r| {
13383                format!(
13384                    "{:.1}",
13385                    r.summary_totals.coverage_branches_hit as f64
13386                        / r.summary_totals.coverage_branches_found as f64
13387                        * 100.0
13388                )
13389            },
13390        );
13391
13392    let cov_no_data_notice = if has_coverage {
13393        String::new()
13394    } else {
13395        String::from(
13396            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13397<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>
13398<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13399  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13400  <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>
13401  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13402  <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>
13403  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13404  <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>
13405  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13406  <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>
13407  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13408  <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>
13409</div>
13410<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13411</div>"#,
13412        )
13413    };
13414
13415    let workspace_density_str = format!("{workspace_density:.1}");
13416    let nonce = &csp_nonce;
13417    let toast_assets = sloc_toast_assets(nonce);
13418    let version = env!("CARGO_PKG_VERSION");
13419
13420    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13421    // of interactive controls — folder watching is managed by the host administrator.
13422    let watched_dirs_html: String = if state.server_mode {
13423        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 \u2014 watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13424    } else {
13425        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13426            r#"<span class="watched-none">No folders watched \u2014 click Choose to add one</span>"#
13427                .to_string()
13428        } else {
13429            watched_dirs_list
13430                .iter()
13431                .fold(String::new(), |mut s, d| {
13432                    use std::fmt::Write as _;
13433                    let escaped =
13434                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13435                    write!(
13436                        s,
13437                        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>"#
13438                    ).expect("write to String is infallible");
13439                    s
13440                })
13441        };
13442        format!(
13443            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>"#
13444        )
13445    };
13446
13447    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13448    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13449
13450    let html = format!(
13451        r#"<!doctype html>
13452<html lang="en">
13453<head>
13454  <meta charset="utf-8" />
13455  <meta name="viewport" content="width=device-width, initial-scale=1" />
13456  <title>OxideSLOC | Test Metrics</title>
13457  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13458  <style nonce="{nonce}">
13459    :root {{
13460      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13461      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13462      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13463      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13464      --info-bg:#eef3ff; --info-text:#4467d8;
13465    }}
13466    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13467    *{{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;}}
13468    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13469    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13470    .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;}}
13471    @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));}}}}
13472    .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);}}
13473    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13474    .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));}}
13475    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13476    .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;}}
13477    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13478    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13479    @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; }} }}
13480    .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;}}
13481    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13482    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13483    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13484    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13485    .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;}}
13486    .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;}}
13487    .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;}}
13488    .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;}}
13489    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13490    .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);}}
13491    .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;}}
13492    .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;}}
13493    .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;}}
13494    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13495    .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;}}
13496    .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);}}
13497    .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;}}
13498    .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;}}
13499    .tz-select:focus{{border-color:var(--oxide);}}
13500    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13501    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13502    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13503    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13504    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13505    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13506    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13507    .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);}}
13508    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13509    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13510    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13511    .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;}}
13512    .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;}}
13513    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13514    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13515    .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);}}
13516    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13517    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13518    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13519    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13520    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13521    .chart-canvas-wrap{{position:relative;height:280px;}}
13522    .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;}}
13523    .chart-no-data svg{{opacity:0.35;}}
13524    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13525    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13526    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13527    .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;}}
13528    .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;}}
13529    .data-table tr:last-child td{{border-bottom:none;}}
13530    .data-table tbody tr:hover td{{background:var(--surface-2);}}
13531    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13532    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13533    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13534    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13535    .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;}}
13536    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13537    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13538    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13539    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13540    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13541    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13542    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13543    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13544    .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;}}
13545    .chart-select:focus{{border-color:var(--accent);}}
13546    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13547    .trend-canvas-wrap{{position:relative;height:260px;}}
13548    .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;}}
13549    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13550    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13551    .site-footer a{{color:var(--muted);}}
13552    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13553    .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;}}
13554    .btn:hover{{background:var(--surface-2);}}
13555    .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;}}
13556    .export-btn:hover{{background:var(--line);}}
13557    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13558    .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;}}
13559    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13560    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13561    .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;}}
13562    .scope-sel:focus{{border-color:var(--accent);}}
13563    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13564    .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;}}
13565    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13566    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13567    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13568    .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;}}
13569    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13570    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13571    .watched-chip-rm:hover{{color:var(--oxide);}}
13572    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13573    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13574    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13575    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13576    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13577    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13578    .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;}}
13579    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13580    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13581    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13582    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13583    .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;}}
13584    .cov-file-search:focus{{border-color:var(--accent);}}
13585    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13586    .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;}}
13587    body.dark-theme .cov-file-search{{background:var(--surface);}}
13588    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13589    .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;}}
13590    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13591    .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;}}
13592    .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);}}
13593    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13594    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13595    .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;}}
13596    .chart-modal-close:hover{{opacity:.7;}}
13597    body.dark-theme .chart-modal{{background:var(--surface);}}
13598  </style>
13599</head>
13600<body>
13601  <div class="background-watermarks" aria-hidden="true">
13602    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13603    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13604    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13605    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13606    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13607    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13608  </div>
13609  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13610  <div class="top-nav">
13611    <div class="top-nav-inner">
13612      <a class="brand" href="/">
13613        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13614        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13615      </a>
13616      <div class="nav-right">
13617        <a class="nav-pill" href="/">Home</a>
13618        <div class="nav-dropdown">
13619          <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>
13620          <div class="nav-dropdown-menu">
13621            <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>
13622          </div>
13623        </div>
13624        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13625        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13626        <div class="nav-dropdown">
13627          <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>
13628          <div class="nav-dropdown-menu">
13629            <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>
13630          </div>
13631        </div>
13632        <div class="server-status-wrap" id="server-status-wrap">
13633          <div class="nav-pill server-online-pill" id="server-status-pill">
13634            <span class="status-dot" id="status-dot"></span>
13635            <span id="server-status-label">Server</span>
13636            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13637          </div>
13638          <div class="server-status-tip">
13639            OxideSLOC is running — accessible on your network.
13640            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13641          </div>
13642        </div>
13643        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13644          <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>
13645        </button>
13646        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13647          <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>
13648          <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>
13649        </button>
13650      </div>
13651    </div>
13652  </div>
13653
13654  <div class="page">
13655    {watched_dirs_html}
13656    <div class="scope-bar">
13657      <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>
13658      <span class="scope-label">Scope</span>
13659      <div class="scope-sel-wrap">
13660        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13661        <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);">
13662          <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>
13663          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13664        </div>
13665      </div>
13666    </div>
13667    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13668      <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>
13669      <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>
13670      <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>
13671      <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>
13672    </div>
13673    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13674      <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>
13675      <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>
13676      <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>
13677      <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>
13678    </div>
13679
13680    <div class="panel" id="viz-panel">
13681      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13682        <span>Visualizations</span>
13683        <div style="display:flex;gap:8px;flex-wrap:wrap;">
13684          <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>
13685          <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>
13686          <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>
13687        </div>
13688      </div>
13689
13690      <div class="chart-box" style="margin-bottom:18px;">
13691        <div class="chart-box-header">
13692          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13693          <div style="display:flex;gap:8px;align-items:center;">
13694            <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>
13695            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13696          </div>
13697        </div>
13698        <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>
13699        <div class="trend-controls-bar">
13700          <label>Y Metric:
13701            <select class="chart-select" id="tm-trend-y">
13702              <option value="test_count" selected>Test Definitions</option>
13703              <option value="code_lines">Code Lines</option>
13704            </select>
13705          </label>
13706          <label>X Axis:
13707            <select class="chart-select" id="tm-trend-x">
13708              <option value="commit" selected>By Commit</option>
13709              <option value="time">By Time</option>
13710            </select>
13711          </label>
13712          <label id="tm-sub-label" style="display:none;">Submodule:
13713            <select class="chart-select" id="tm-trend-sub">
13714              <option value="">All (project total)</option>
13715            </select>
13716          </label>
13717          <label>Chart Size:
13718            <select class="chart-select" id="tm-trend-size">
13719              <option value="200">Compact</option>
13720              <option value="260" selected>Normal</option>
13721              <option value="360">Large</option>
13722            </select>
13723          </label>
13724        </div>
13725        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13726        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13727      </div>
13728
13729      <div class="chart-row">
13730        <div class="chart-box">
13731          <div class="chart-box-header">
13732            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13733            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13734          </div>
13735          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13736          <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>
13737        </div>
13738        <div class="chart-box">
13739          <div class="chart-box-header">
13740            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13741            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13742          </div>
13743          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13744          <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>
13745        </div>
13746      </div>
13747
13748      <div class="chart-row">
13749        <div class="chart-box">
13750          <div class="chart-box-header">
13751            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13752            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13753          </div>
13754          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13755          <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>
13756        </div>
13757        <div class="chart-box" id="suites-chart-box">
13758          <div class="chart-box-header">
13759            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13760            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13761          </div>
13762          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13763          <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>
13764        </div>
13765      </div>
13766
13767      <div class="chart-row">
13768        <div class="chart-box">
13769          <div class="chart-box-title">Test Files Breakdown</div>
13770          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13771          <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>
13772        </div>
13773        <div class="chart-box">
13774          <div class="chart-box-title">Test Composition</div>
13775          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13776          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13777          <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>
13778        </div>
13779      </div>
13780    </div>
13781
13782    <div class="panel">
13783      <h1>Test Metrics</h1>
13784      <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>
13785
13786      <div class="section-header">Language Breakdown</div>
13787      {cov_no_data_notice}
13788      <div style="overflow-x:auto;">
13789        <table class="data-table" id="lang-table">
13790          <thead><tr>
13791            <th>Language</th>
13792            <th class="num">Test Fns</th>
13793            <th class="num">Assertions</th>
13794            <th class="num">Suites</th>
13795            <th class="num">Code Lines</th>
13796            <th class="num">Files</th>
13797            <th class="num">Density / 1K</th>
13798            <th>Relative Density</th>
13799          </tr></thead>
13800          <tbody id="lang-tbody"></tbody>
13801        </table>
13802      </div>
13803    </div>
13804
13805    <div class="panel" id="cov-panel" style="display:none;">
13806      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
13807      <div class="cov-gauge-row" id="cov-gauges">
13808        <div class="cov-gauge-card">
13809          <div class="cov-gauge-label">Line Coverage</div>
13810          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
13811          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
13812          <div class="cov-gauge-sub">Lines hit / instrumented</div>
13813        </div>
13814        <div class="cov-gauge-card">
13815          <div class="cov-gauge-label">Function Coverage</div>
13816          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
13817          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
13818          <div class="cov-gauge-sub">Functions hit / found</div>
13819        </div>
13820        <div class="cov-gauge-card">
13821          <div class="cov-gauge-label">Branch Coverage</div>
13822          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
13823          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
13824          <div class="cov-gauge-sub">Branches hit / found</div>
13825        </div>
13826      </div>
13827      <div class="chart-row">
13828        <div class="chart-box">
13829          <div class="chart-box-title">Line Coverage % by Language</div>
13830          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
13831        </div>
13832        <div class="chart-box">
13833          <div class="chart-box-title">Coverage Tier Distribution</div>
13834          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
13835        </div>
13836      </div>
13837
13838      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
13839      <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>
13840      <div class="cov-file-toolbar">
13841        <div class="cov-filter-tabs" id="cov-filter-tabs">
13842          <button class="cov-tab active" data-tier="all">All</button>
13843          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
13844          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
13845          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
13846          <button class="cov-tab" data-tier="high">High (≥80%)</button>
13847        </div>
13848        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename\u2026">
13849      </div>
13850      <div style="overflow-x:auto;">
13851        <table class="data-table" id="cov-file-table">
13852          <thead><tr>
13853            <th>File</th>
13854            <th>Lang</th>
13855            <th class="num">Line %</th>
13856            <th class="num">Lines Hit / Found</th>
13857            <th class="num">Fn %</th>
13858            <th class="num">Fns Hit / Found</th>
13859          </tr></thead>
13860          <tbody id="cov-file-tbody"></tbody>
13861        </table>
13862      </div>
13863      <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>
13864      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
13865    </div>
13866
13867  </div>
13868
13869  <footer class="site-footer">
13870    local code analysis - metrics, history and reports
13871    &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>
13872    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13873    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13874    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13875    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13876  </footer>
13877
13878  <script nonce="{nonce}">
13879  (function() {{
13880    // Theme
13881    var b = document.body;
13882    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
13883    var tgl = document.getElementById('theme-toggle');
13884    if (tgl) tgl.addEventListener('click', function() {{
13885      var d = b.classList.toggle('dark-theme');
13886      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
13887    }});
13888
13889    // Watermarks
13890    (function() {{
13891      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13892      if (!wms.length) return;
13893      var placed = [];
13894      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;}}
13895      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];}}
13896      var half=Math.floor(wms.length/2);
13897      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;}});
13898    }})();
13899
13900    // Code particles
13901    (function() {{
13902      var container = document.getElementById('code-particles');
13903      if (!container) return;
13904      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
13905      for (var i = 0; i < 36; i++) {{
13906        (function(idx) {{
13907          var el = document.createElement('span');
13908          el.className = 'code-particle';
13909          el.textContent = snippets[idx % snippets.length];
13910          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
13911          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
13912          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
13913          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';
13914          container.appendChild(el);
13915        }})(i);
13916      }}
13917    }})();
13918
13919    // Settings modal
13920    (function() {{
13921      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'}}];
13922      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);}});}}
13923      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
13924      var btn=document.getElementById('settings-btn');if(!btn)return;
13925      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13926      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>';
13927      document.body.appendChild(m);
13928      var g=document.getElementById('scheme-grid');
13929      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);}});
13930      var cl=document.getElementById('settings-close');
13931      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');}});
13932      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13933      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13934    }})();
13935
13936    // Watched folder picker
13937    (function() {{
13938      var btn = document.getElementById('add-watched-btn');
13939      if (!btn) return;
13940      btn.addEventListener('click', function() {{
13941        fetch('/pick-directory?kind=reports')
13942          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
13943          .then(function(data) {{
13944            if (!data.cancelled && data.selected_path) {{
13945              var form = document.createElement('form');
13946              form.method = 'POST';
13947              form.action = '/watched-dirs/add';
13948              var ri = document.createElement('input');
13949              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
13950              var fi = document.createElement('input');
13951              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
13952              form.appendChild(ri); form.appendChild(fi);
13953              document.body.appendChild(form);
13954              form.submit();
13955            }}
13956          }})
13957          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
13958      }});
13959    }})();
13960  }})();
13961  </script>
13962
13963  <script src="/static/chart.js" nonce="{nonce}"></script>
13964  <script nonce="{nonce}">
13965  (function() {{
13966    var SCOPE_DATA = {scope_data_json};
13967    var currentRoot = '__all__';
13968    var currentSub  = '';
13969    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
13970    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
13971    var ALL_CHARTS = [];
13972    var currentLangTests = [];
13973    var currentTrendPts = [];
13974
13975    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();}}
13976    function fmtFull(n){{return Number(n).toLocaleString();}}
13977    function isDark(){{return document.body.classList.contains('dark-theme');}}
13978    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
13979    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
13980    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
13981
13982    function makeDlPlugin(fmtFn, anchor) {{
13983      return {{
13984        afterDatasetsDraw: function(chart) {{
13985          var ctx = chart.ctx;
13986          var tc = txtClr();
13987          chart.data.datasets.forEach(function(ds, di) {{
13988            var meta = chart.getDatasetMeta(di);
13989            meta.data.forEach(function(el, idx) {{
13990              var label = fmtFn(ds.data[idx], di, idx);
13991              if (label == null || label === '') return;
13992              ctx.save();
13993              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
13994              ctx.fillStyle = tc;
13995              if (anchor === 'top') {{
13996                ctx.textAlign = 'center';
13997                ctx.textBaseline = 'bottom';
13998                ctx.fillText(String(label), el.x, el.y - 5);
13999              }} else {{
14000                ctx.textAlign = 'left';
14001                ctx.textBaseline = 'middle';
14002                ctx.fillText(String(label), el.x + 5, el.y);
14003              }}
14004              ctx.restore();
14005            }});
14006          }});
14007        }}
14008      }};
14009    }}
14010
14011    // Cursor: pointer over chart data, default over empty chart area.
14012    function chartCursor(e, els) {{
14013      var t = e.native && e.native.target;
14014      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14015    }}
14016    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14017
14018    // Plugin: draws % labels inside each doughnut slice.
14019    var donutPctPlugin = {{
14020      afterDatasetsDraw: function(chart) {{
14021        var ctx = chart.ctx;
14022        chart.data.datasets.forEach(function(ds, di) {{
14023          var meta = chart.getDatasetMeta(di);
14024          if (meta.hidden) return;
14025          var total = 0;
14026          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14027          if (!total) return;
14028          meta.data.forEach(function(arc, i) {{
14029            if (arc.hidden) return;
14030            var val = ds.data[i] || 0;
14031            var pct = val / total * 100;
14032            if (pct < 3) return;
14033            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14034            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14035            var tx = arc.x + midR * Math.cos(midAngle);
14036            var ty = arc.y + midR * Math.sin(midAngle);
14037            ctx.save();
14038            ctx.textAlign = 'center';
14039            ctx.textBaseline = 'middle';
14040            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14041            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14042            ctx.shadowBlur = 3;
14043            ctx.fillStyle = '#fff';
14044            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14045            ctx.restore();
14046          }});
14047        }});
14048      }}
14049    }};
14050
14051    function makeTmOverlay(title, subtitle, h) {{
14052      var overlay = document.createElement('div');
14053      overlay.className = 'chart-modal-overlay';
14054      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14055      var ch = Math.min(h || 560, maxH);
14056      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14057      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>';
14058      document.body.appendChild(overlay);
14059      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14060      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14061      return document.getElementById('tm-modal-canvas');
14062    }}
14063
14064    function getDataset() {{
14065      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14066      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14067      return r;
14068    }}
14069    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14070
14071    function showNoData(id, show) {{
14072      var el = document.getElementById(id);
14073      if (!el) return;
14074      var wrap = el.previousElementSibling;
14075      el.style.display = show ? '' : 'none';
14076      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14077    }}
14078
14079    function renderTestCharts(D) {{
14080      currentLangTests = D || [];
14081      testsChart = destroyChart(testsChart);
14082      densityChart = destroyChart(densityChart);
14083      if (!D || !D.length) {{
14084        showNoData('no-data-tests', true);
14085        showNoData('no-data-density', true);
14086        return;
14087      }}
14088      showNoData('no-data-tests', false);
14089      showNoData('no-data-density', false);
14090      var top15 = D.slice(0, 15);
14091      var canvas1 = document.getElementById('canvas-tests');
14092      if (canvas1) {{
14093        testsChart = new Chart(canvas1, {{
14094          type: 'bar',
14095          data: {{
14096            labels: top15.map(function(d){{ return d.lang; }}),
14097            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14098          }},
14099          options: {{
14100            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14101            layout: {{ padding: {{ right: 64 }} }},
14102            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14103            scales: {{
14104              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14105              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14106            }}
14107          }},
14108          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14109        }});
14110        ALL_CHARTS.push(testsChart);
14111      }}
14112      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14113      var canvas2 = document.getElementById('canvas-density');
14114      if (canvas2) {{
14115        densityChart = new Chart(canvas2, {{
14116          type: 'bar',
14117          data: {{
14118            labels: topD.map(function(d){{ return d.lang; }}),
14119            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 }}]
14120          }},
14121          options: {{
14122            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14123            layout: {{ padding: {{ right: 64 }} }},
14124            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14125            scales: {{
14126              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14127              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14128            }}
14129          }},
14130          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14131        }});
14132        ALL_CHARTS.push(densityChart);
14133      }}
14134    }}
14135
14136    function renderAssertionsChart(D) {{
14137      assertionsChart = destroyChart(assertionsChart);
14138      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14139      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14140      var canvas = document.getElementById('canvas-assertions');
14141      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14142      showNoData('no-data-assertions', false);
14143      assertionsChart = new Chart(canvas, {{
14144        type: 'bar',
14145        data: {{
14146          labels: top15.map(function(d){{ return d.lang; }}),
14147          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14148        }},
14149        options: {{
14150          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14151          layout: {{ padding: {{ right: 64 }} }},
14152          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14153          scales: {{
14154            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14155            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14156          }}
14157        }},
14158        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14159      }});
14160      ALL_CHARTS.push(assertionsChart);
14161    }}
14162
14163    function renderSuitesChart(D) {{
14164      suitesChart = destroyChart(suitesChart);
14165      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14166      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14167      var canvas = document.getElementById('canvas-suites');
14168      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14169      showNoData('no-data-suites', false);
14170      suitesChart = new Chart(canvas, {{
14171        type: 'bar',
14172        data: {{
14173          labels: top15.map(function(d){{ return d.lang; }}),
14174          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 }}]
14175        }},
14176        options: {{
14177          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14178          layout: {{ padding: {{ right: 64 }} }},
14179          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14180          scales: {{
14181            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14182            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14183          }}
14184        }},
14185        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14186      }});
14187      ALL_CHARTS.push(suitesChart);
14188    }}
14189
14190    function renderFilesChart(totals) {{
14191      filesChart = destroyChart(filesChart);
14192      var canvas = document.getElementById('canvas-files');
14193      if (!canvas) return;
14194      var testF = totals.test_files || 0;
14195      var totalF = totals.total_files || 0;
14196      var nonTest = Math.max(0, totalF - testF);
14197      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14198      showNoData('no-data-files', false);
14199      var dark = isDark();
14200      filesChart = new Chart(canvas, {{
14201        type: 'doughnut',
14202        data: {{
14203          labels: ['Test Files', 'Non-Test Files'],
14204          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14205        }},
14206        options: {{
14207          responsive: true, maintainAspectRatio: false, cutout: '62%',
14208          onHover: chartCursor,
14209          plugins: {{
14210            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14211              generateLabels: function(chart) {{
14212                var ds = chart.data.datasets[0];
14213                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14214                return chart.data.labels.map(function(lbl, i) {{
14215                  var val = ds.data[i] || 0;
14216                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14217                  return {{
14218                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14219                    fillStyle: ds.backgroundColor[i],
14220                    strokeStyle: ds.borderColor,
14221                    lineWidth: ds.borderWidth,
14222                    hidden: false,
14223                    index: i,
14224                    datasetIndex: 0
14225                  }};
14226                }});
14227              }}
14228            }},
14229              onHover: function(e, item, leg) {{
14230                var ch = leg.chart;
14231                var t = e.native && e.native.target;
14232                if (t) t.style.cursor = 'pointer';
14233                ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14234                ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14235                ch.update();
14236              }},
14237              onLeave: function(e, item, leg) {{
14238                var ch = leg.chart;
14239                var t = e.native && e.native.target;
14240                if (t) t.style.cursor = 'default';
14241                ch.setActiveElements([]);
14242                ch.tooltip.setActiveElements([], {{}});
14243                ch.update('none');
14244              }}
14245            }},
14246            tooltip: {{ callbacks: {{ label: function(ctx) {{
14247              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14248              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14249            }} }} }}
14250          }}
14251        }},
14252        plugins: [donutPctPlugin]
14253      }});
14254      ALL_CHARTS.push(filesChart);
14255    }}
14256
14257    function renderCompositionChart(totals) {{
14258      compositionChart = destroyChart(compositionChart);
14259      var canvas = document.getElementById('canvas-composition');
14260      if (!canvas) return;
14261      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14262      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14263      showNoData('no-data-composition', false);
14264      compositionChart = new Chart(canvas, {{
14265        type: 'bar',
14266        data: {{
14267          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14268          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14269        }},
14270        options: {{
14271          responsive: true, maintainAspectRatio: false,
14272          layout: {{ padding: {{ top: 22 }} }},
14273          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14274          scales: {{
14275            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14276            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14277          }}
14278        }},
14279        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14280      }});
14281      ALL_CHARTS.push(compositionChart);
14282    }}
14283
14284    function renderCovCharts(covD, tiers) {{
14285      covChart = destroyChart(covChart);
14286      tierChart = destroyChart(tierChart);
14287      var covCanvas = document.getElementById('canvas-cov');
14288      if (covCanvas && covD && covD.length) {{
14289        covChart = new Chart(covCanvas, {{
14290          type: 'bar',
14291          data: {{
14292            labels: covD.map(function(d){{ return d.lang; }}),
14293            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 }}]
14294          }},
14295          options: {{
14296            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14297            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14298            scales: {{
14299              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14300              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14301            }}
14302          }}
14303        }});
14304        ALL_CHARTS.push(covChart);
14305      }}
14306      var tierCanvas = document.getElementById('canvas-cov-tiers');
14307      if (tierCanvas && tiers) {{
14308        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14309        tierChart = new Chart(tierCanvas, {{
14310          type: 'doughnut',
14311          data: {{
14312            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14313            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14314          }},
14315          options: {{
14316            responsive: true, maintainAspectRatio: false, cutout: '62%',
14317            onHover: chartCursor,
14318            plugins: {{
14319              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14320                onHover: function(e, item, leg) {{
14321                  var ch = leg.chart;
14322                  var t = e.native && e.native.target;
14323                  if (t) t.style.cursor = 'pointer';
14324                  ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14325                  ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14326                  ch.update();
14327                }},
14328                onLeave: function(e, item, leg) {{
14329                  var ch = leg.chart;
14330                  var t = e.native && e.native.target;
14331                  if (t) t.style.cursor = 'default';
14332                  ch.setActiveElements([]);
14333                  ch.tooltip.setActiveElements([], {{}});
14334                  ch.update('none');
14335                }}
14336              }},
14337              tooltip: {{ callbacks: {{ label: function(ctx) {{
14338                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14339                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14340              }} }} }}
14341            }}
14342          }},
14343          plugins: [donutPctPlugin]
14344        }});
14345        ALL_CHARTS.push(tierChart);
14346      }}
14347    }}
14348
14349    function buildLangTable(D) {{
14350      var tbody = document.getElementById('lang-tbody');
14351      if (!tbody) return;
14352      if (!D || !D.length) {{
14353        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>';
14354        return;
14355      }}
14356      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14357      tbody.innerHTML = D.map(function(d) {{
14358        var barW = Math.round(d.density / maxDensity * 120);
14359        return '<tr>' +
14360          '<td><strong>' + d.lang + '</strong></td>' +
14361          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14362          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14363          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14364          '<td class="num">' + fmtFull(d.code) + '</td>' +
14365          '<td class="num">' + fmtFull(d.files) + '</td>' +
14366          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14367          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14368          '</tr>';
14369      }}).join('');
14370    }}
14371
14372    var covFileData = [];
14373    var covFileTier = 'all';
14374    var covFileSearch = '';
14375
14376    function pctBadge(pct) {{
14377      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14378      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14379      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14380    }}
14381
14382    function buildCovFileTable() {{
14383      var tbody = document.getElementById('cov-file-tbody');
14384      var empty = document.getElementById('cov-file-empty');
14385      var count = document.getElementById('cov-file-count');
14386      if (!tbody) return;
14387      var srch = covFileSearch.toLowerCase();
14388      var filtered = covFileData.filter(function(f) {{
14389        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14390        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14391        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14392        if (covFileTier === 'high' && f.line_pct < 80) return false;
14393        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14394        return true;
14395      }});
14396      if (!filtered.length) {{
14397        tbody.innerHTML = '';
14398        if (empty) empty.style.display = '';
14399        if (count) count.textContent = '';
14400        return;
14401      }}
14402      if (empty) empty.style.display = 'none';
14403      var shown = Math.min(filtered.length, 500);
14404      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14405      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14406        var fnCol = f.fn_pct < 0
14407          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14408          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14409        return '<tr>' +
14410          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
14411          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14412          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14413          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14414          fnCol +
14415          '</tr>';
14416      }}).join('');
14417    }}
14418
14419    (function() {{
14420      var tabs = document.getElementById('cov-filter-tabs');
14421      if (tabs) {{
14422        tabs.addEventListener('click', function(e) {{
14423          var btn = e.target.closest('.cov-tab');
14424          if (!btn) return;
14425          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14426          btn.classList.add('active');
14427          covFileTier = btn.getAttribute('data-tier');
14428          buildCovFileTable();
14429        }});
14430      }}
14431      var srch = document.getElementById('cov-file-search');
14432      if (srch) {{
14433        srch.addEventListener('input', function() {{
14434          covFileSearch = this.value;
14435          buildCovFileTable();
14436        }});
14437      }}
14438    }})();
14439
14440    function updateCovGauges(t) {{
14441      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14442      var el;
14443      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14444      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14445      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14446      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14447      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14448      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14449    }}
14450
14451    function applyScope() {{
14452      var d = getDataset();
14453      var t = d.totals;
14454      var el;
14455      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14456      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14457      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14458      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14459      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14460      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14461      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14462      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14463      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14464      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14465      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14466      renderTestCharts(d.lang_tests);
14467      renderAssertionsChart(d.lang_tests);
14468      renderSuitesChart(d.lang_tests);
14469      renderFilesChart(t);
14470      renderCompositionChart(t);
14471      buildLangTable(d.lang_tests);
14472      var covPanel = document.getElementById('cov-panel');
14473      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14474      if (d.has_coverage) {{
14475        renderCovCharts(d.cov, d.cov_tiers);
14476        updateCovGauges(t);
14477        covFileData = d.file_cov || [];
14478        covFileTier = 'all';
14479        covFileSearch = '';
14480        var tabs = document.getElementById('cov-filter-tabs');
14481        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14482        var srch = document.getElementById('cov-file-search');
14483        if (srch) srch.value = '';
14484        buildCovFileTable();
14485      }}
14486      loadTrend();
14487    }}
14488
14489    // Populate scope-root-sel from SCOPE_DATA keys
14490    (function() {{
14491      var sel = document.getElementById('scope-root-sel');
14492      if (!sel) return;
14493      Object.keys(SCOPE_DATA).forEach(function(k) {{
14494        if (k === '__all__') return;
14495        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14496      }});
14497    }})();
14498
14499    document.getElementById('scope-root-sel').addEventListener('change', function() {{
14500      currentRoot = this.value;
14501      currentSub = '';
14502      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14503      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14504      var subWrap = document.getElementById('scope-sub-wrap');
14505      var subSel  = document.getElementById('scope-sub-sel');
14506      subSel.innerHTML = '<option value="">Entire project</option>';
14507      if (subNames.length) {{
14508        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14509        subWrap.style.display = 'flex';
14510      }} else {{
14511        subWrap.style.display = 'none';
14512      }}
14513      applyScope();
14514    }});
14515
14516    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14517      currentSub = this.value;
14518      applyScope();
14519    }});
14520
14521    var allTrendData = [];
14522
14523    var TM_Y_META = {{
14524      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14525      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
14526    }};
14527
14528    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14529    function hexRgb(hex) {{
14530      var h = String(hex).replace('#', '');
14531      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14532      var n = parseInt(h, 16);
14533      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14534    }}
14535    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14536    // tint at the top to transparent at the bottom (no flat solid block).
14537    function tmTrendGradient(ctx2, chartArea, color) {{
14538      var rgb = hexRgb(color);
14539      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14540      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
14541      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14542      g.addColorStop(1,   'rgba(' + rgb + ',0)');
14543      return g;
14544    }}
14545
14546    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14547    // so linear interpolation between adjacent points matches the drawn line).
14548    function tmLineYAt(chart, px) {{
14549      var meta = chart.getDatasetMeta(0);
14550      if (!meta || !meta.data || !meta.data.length) return null;
14551      var d = meta.data;
14552      if (px <= d[0].x) return d[0].y;
14553      for (var i = 1; i < d.length; i++) {{
14554        if (px <= d[i].x) {{
14555          var span = d[i].x - d[i - 1].x;
14556          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14557          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14558        }}
14559      }}
14560      return d[d.length - 1].y;
14561    }}
14562
14563    // Plugin: only show the tooltip / finger cursor when the pointer is over the
14564    // gradient fill (inside the plot and at/below the line) — never in the empty
14565    // space above the line. Outside the fill we retype the event as 'mouseout' so
14566    // the core interaction dismisses any active tooltip on its own.
14567    var tmFillGuard = {{
14568      id: 'tmFillGuard',
14569      beforeEvent: function(chart, args) {{
14570        var e = args.event;
14571        if (!e || e.type !== 'mousemove') return;
14572        var ca = chart.chartArea;
14573        if (!ca) return;
14574        var inFill = false;
14575        if (e.x >= ca.left && e.x <= ca.right) {{
14576          var ly = tmLineYAt(chart, e.x);
14577          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14578        }}
14579        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14580        if (!inFill) {{ e.type = 'mouseout'; }}
14581      }}
14582    }};
14583
14584    // Single source of truth for the test-metrics trend chart config so the inline
14585    // chart and the Full View modal render identically (straight segments, gradient
14586    // fill, white-ringed points, gradient-only interactivity).
14587    function buildTmTrendConfig(pts, ctrl, meta) {{
14588      return {{
14589        type: 'line',
14590        data: {{
14591          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14592          datasets: [{{
14593            label: meta.label,
14594            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14595            borderColor: meta.color,
14596            borderWidth: 2.5,
14597            backgroundColor: function(context) {{
14598              var ca = context.chart.chartArea;
14599              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14600              return tmTrendGradient(context.chart.ctx, ca, meta.color);
14601            }},
14602            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14603            pointBorderColor: '#fff',
14604            pointBorderWidth: 2,
14605            pointRadius: 6,
14606            pointHoverRadius: 9,
14607            pointHoverBorderWidth: 2.5,
14608            fill: true, tension: 0
14609          }}]
14610        }},
14611        options: {{
14612          responsive: true, maintainAspectRatio: false,
14613          layout: {{ padding: {{ top: 22 }} }},
14614          interaction: {{ mode: 'index', intersect: false }},
14615          plugins: {{
14616            legend: {{ display: false }},
14617            tooltip: {{
14618              mode: 'index', intersect: false,
14619              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14620            }}
14621          }},
14622          scales: {{
14623            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14624            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14625          }}
14626        }},
14627        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14628      }};
14629    }}
14630
14631    function getTrendControls() {{
14632      var ySel    = document.getElementById('tm-trend-y');
14633      var xSel    = document.getElementById('tm-trend-x');
14634      var sizeSel = document.getElementById('tm-trend-size');
14635      var subSel  = document.getElementById('tm-trend-sub');
14636      return {{
14637        yKey:    ySel    ? ySel.value    : 'test_count',
14638        xMode:   xSel    ? xSel.value    : 'commit',
14639        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
14640        submod:  subSel  ? subSel.value  : ''
14641      }};
14642    }}
14643
14644    function makeTrendLabel(d, xMode) {{
14645      if (xMode === 'commit') {{
14646        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14647      }}
14648      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14649    }}
14650
14651    function buildTrend(data) {{
14652      allTrendData = data || [];
14653      renderTrend();
14654    }}
14655
14656    function renderTrend() {{
14657      var data = allTrendData;
14658      var ctrl = getTrendControls();
14659      var trendCanvas = document.getElementById('canvas-trend');
14660      var trendWrap   = document.getElementById('trend-canvas-wrap');
14661      var trendEmpty  = document.getElementById('trend-empty');
14662
14663      // Apply chart size
14664      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14665
14666      // Filter by submodule if selected (entries from project_label match)
14667      var pts = data.slice().reverse();
14668      if (ctrl.submod) {{
14669        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14670      }}
14671
14672      currentTrendPts = pts;
14673
14674      if (!pts.length) {{
14675        if (trendCanvas) trendCanvas.style.display = 'none';
14676        if (trendEmpty) trendEmpty.style.display = '';
14677        return;
14678      }}
14679      if (trendCanvas) trendCanvas.style.display = '';
14680      if (trendEmpty) trendEmpty.style.display = 'none';
14681
14682      trendChart = destroyChart(trendChart);
14683      if (!trendCanvas) return;
14684
14685      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14686
14687      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14688      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14689      ALL_CHARTS.push(trendChart);
14690
14691      // Populate submodule selector from unique project_labels
14692      var subSel = document.getElementById('tm-trend-sub');
14693      var subLabel = document.getElementById('tm-sub-label');
14694      if (subSel && data.length) {{
14695        var projects = [];
14696        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14697        if (projects.length > 1) {{
14698          var curVal = subSel.value;
14699          subSel.innerHTML = '<option value="">All (project total)</option>';
14700          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14701          if (subLabel) subLabel.style.display = '';
14702        }} else {{
14703          if (subLabel) subLabel.style.display = 'none';
14704        }}
14705      }}
14706    }}
14707
14708    // ── Full View expand buttons ──────────────────────────────────────────────
14709    (function() {{
14710      var btn = document.getElementById('tests-expand-btn');
14711      if (!btn) return;
14712      btn.addEventListener('click', function() {{
14713        var D = currentLangTests;
14714        if (!D || !D.length) return;
14715        var top15 = D.slice(0, 15);
14716        var h = Math.max(320, top15.length * 36 + 80);
14717        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14718        if (!canvas) return;
14719        new Chart(canvas, {{
14720          type: 'bar',
14721          data: {{
14722            labels: top15.map(function(d){{ return d.lang; }}),
14723            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14724          }},
14725          options: {{
14726            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14727            layout: {{ padding: {{ right: 72 }} }},
14728            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14729            scales: {{
14730              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14731              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14732            }}
14733          }},
14734          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14735        }});
14736      }});
14737    }})();
14738
14739    (function() {{
14740      var btn = document.getElementById('density-expand-btn');
14741      if (!btn) return;
14742      btn.addEventListener('click', function() {{
14743        var D = currentLangTests;
14744        if (!D || !D.length) return;
14745        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14746        var h = Math.max(320, topD.length * 36 + 80);
14747        var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14748        if (!canvas) return;
14749        new Chart(canvas, {{
14750          type: 'bar',
14751          data: {{
14752            labels: topD.map(function(d){{ return d.lang; }}),
14753            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 }}]
14754          }},
14755          options: {{
14756            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14757            layout: {{ padding: {{ right: 72 }} }},
14758            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14759            scales: {{
14760              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14761              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14762            }}
14763          }},
14764          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14765        }});
14766      }});
14767    }})();
14768
14769    (function() {{
14770      var btn = document.getElementById('trend-expand-btn');
14771      if (!btn) return;
14772      btn.addEventListener('click', function() {{
14773        var pts = currentTrendPts;
14774        if (!pts || !pts.length) return;
14775        var ctrl = getTrendControls();
14776        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14777        var title = meta.label + ' Trend \u2014 Full View';
14778        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14779        if (!canvas) return;
14780        // Reuse the exact inline-chart config so Full View matches the default view
14781        // (straight segments + gradient-only interactivity), just larger.
14782        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14783      }});
14784    }})();
14785
14786    (function() {{
14787      var btn = document.getElementById('assertions-expand-btn');
14788      if (!btn) return;
14789      btn.addEventListener('click', function() {{
14790        var D = currentLangTests;
14791        if (!D || !D.length) return;
14792        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14793        if (!top15.length) return;
14794        var h = Math.max(320, top15.length * 36 + 80);
14795        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14796        if (!canvas) return;
14797        new Chart(canvas, {{
14798          type: 'bar',
14799          data: {{
14800            labels: top15.map(function(d){{ return d.lang; }}),
14801            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14802          }},
14803          options: {{
14804            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14805            layout: {{ padding: {{ right: 72 }} }},
14806            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14807            scales: {{
14808              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14809              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14810            }}
14811          }},
14812          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14813        }});
14814      }});
14815    }})();
14816
14817    (function() {{
14818      var btn = document.getElementById('suites-expand-btn');
14819      if (!btn) return;
14820      btn.addEventListener('click', function() {{
14821        var D = currentLangTests;
14822        if (!D || !D.length) return;
14823        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14824        if (!top15.length) return;
14825        var h = Math.max(320, top15.length * 36 + 80);
14826        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
14827        if (!canvas) return;
14828        new Chart(canvas, {{
14829          type: 'bar',
14830          data: {{
14831            labels: top15.map(function(d){{ return d.lang; }}),
14832            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 }}]
14833          }},
14834          options: {{
14835            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14836            layout: {{ padding: {{ right: 72 }} }},
14837            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14838            scales: {{
14839              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14840              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14841            }}
14842          }},
14843          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14844        }});
14845      }});
14846    }})();
14847
14848    // Wire trend control selectors — re-render without re-fetching
14849    (function() {{
14850      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
14851        var el = document.getElementById(id);
14852        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
14853      }});
14854    }})();
14855
14856    function loadTrend() {{
14857      var url = '/api/metrics/history?limit=100';
14858      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
14859      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
14860        buildTrend(data);
14861        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
14862        var btn = document.getElementById('multi-compare-trend-btn');
14863        if (btn) {{
14864          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
14865          if (ids.length >= 2) {{
14866            btn.style.display = '';
14867            btn.onclick = function() {{
14868              // Reverse so oldest first (API returns newest first).
14869              var sorted = ids.slice().reverse();
14870              if (sorted.length === 2) {{
14871                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
14872              }} else {{
14873                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
14874              }}
14875            }};
14876          }} else {{
14877            btn.style.display = 'none';
14878          }}
14879        }}
14880      }}).catch(function(){{
14881        var trendEmpty = document.getElementById('trend-empty');
14882        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
14883      }});
14884    }}
14885
14886    // Re-render charts on theme toggle
14887    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
14888      setTimeout(function() {{
14889        ALL_CHARTS.forEach(function(c) {{
14890          if (c && c.options && c.options.scales) {{
14891            Object.values(c.options.scales).forEach(function(ax) {{
14892              if (ax.grid) ax.grid.color = clr();
14893              if (ax.ticks) ax.ticks.color = txtClr();
14894            }});
14895            c.update();
14896          }}
14897        }});
14898      }}, 80);
14899    }});
14900
14901    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
14902    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14903    function tmExportMeta() {{
14904      var sel = document.getElementById('scope-sel');
14905      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
14906      if (!proj || proj === '__all__') proj = 'All projects';
14907      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
14908      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
14909      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
14910      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
14911      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
14912    }}
14913
14914    function exportTmXLSX() {{
14915      var D = currentLangTests;
14916      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
14917      var t = tmExportMeta();
14918      function s2b(s) {{ return new TextEncoder().encode(s); }}
14919      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
14920      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; }}
14921      function crc32(d) {{
14922        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;}}}}
14923        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
14924      }}
14925      // Store all cells as strings so Excel left-aligns uniformly.
14926      function cs(addr, val, bold) {{
14927        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
14928      }}
14929      // Build an Excel Table XML definition for a given sheet range and columns.
14930      function makeTableXml(tblId, name, ref, cols) {{
14931        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
14932        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14933        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
14934        x+='<autoFilter ref="'+ref+'"/>';
14935        x+='<tableColumns count="'+cols.length+'">';
14936        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
14937        x+='</tableColumns>';
14938        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
14939        return x+'</table>';
14940      }}
14941      // Worksheet XML with optional Excel Table part reference.
14942      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
14943        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14944        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
14945        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
14946        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
14947        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
14948        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>';}});
14949        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>';}}
14950        x+='</sheetData>';
14951        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
14952        return x+'</worksheet>';
14953      }}
14954
14955      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
14956      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
14957      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
14958      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
14959      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
14960      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
14961
14962      // Sheet 1: Summary
14963      var sumHdr=['Metric','Value'];
14964      var sumRows=[
14965        ['Project / Scope', t.proj],
14966        ['Export Date', t.full],
14967        ['Test Functions', Number(totTests).toLocaleString()],
14968        ['Assertions', Number(totAssert).toLocaleString()],
14969        ['Test Suites', Number(totSuites).toLocaleString()],
14970        ['Languages with Tests', String(D.length)],
14971        ['Total Code Lines', Number(totCode).toLocaleString()],
14972        ['Average Density (per 1K)', String(avgDensity)],
14973      ];
14974      var sumRef='A1:B'+(sumRows.length+1);
14975      var sumTblXml=makeTableXml(1,'Summary',sumRef,sumHdr);
14976      var sumSheetXml=buildSheet(sumHdr,sumRows,null,[28,22],'rId1');
14977
14978      // Sheet 2: Language Breakdown
14979      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
14980      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)];}});
14981      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
14982      // Table covers header + data only (TOTAL row excluded from table, sits just below)
14983      var langDataRows=langRows.length;
14984      var langTblRef='A1:G'+(langDataRows+1);
14985      var langTblXml=makeTableXml(2,'LangBreakdown',langTblRef,langHdr);
14986      var langSheetXml=buildSheet(langHdr,langRows,totRow,[22,15,15,15,15,12,15],'rId1');
14987
14988      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>';
14989      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>';
14990      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>';
14991      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>';
14992      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>';
14993      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>';
14994      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>';
14995      var files=[
14996        {{name:'[Content_Types].xml',data:s2b(ct)}},
14997        {{name:'_rels/.rels',data:s2b(dotrels)}},
14998        {{name:'xl/workbook.xml',data:s2b(wbx)}},
14999        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15000        {{name:'xl/styles.xml',data:s2b(styl)}},
15001        {{name:'xl/worksheets/sheet1.xml',data:s2b(sumSheetXml)}},
15002        {{name:'xl/worksheets/sheet2.xml',data:s2b(langSheetXml)}},
15003        {{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b(sh1rels)}},
15004        {{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b(sh2rels)}},
15005        {{name:'xl/tables/table1.xml',data:s2b(sumTblXml)}},
15006        {{name:'xl/tables/table2.xml',data:s2b(langTblXml)}},
15007      ];
15008      var parts=[],offsets=[],total=0;
15009      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;}});
15010      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;}});
15011      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));
15012      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;}});
15013      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15014      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15015      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15016      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15017    }}
15018
15019    function exportTmPNG() {{
15020      // Map canvas IDs to display titles
15021      var CHART_TITLES = {{
15022        'canvas-trend':       'TEST COUNT TREND',
15023        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15024        'canvas-density':     'TEST DENSITY (PER 1,000 CODE LINES)',
15025        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15026        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15027        'canvas-files':       'TEST FILES BREAKDOWN',
15028        'canvas-composition': 'TEST COMPOSITION'
15029      }};
15030      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15031      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15032      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15033      var t=tmExportMeta();
15034      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15035      var trendCanvas=document.getElementById('canvas-trend');
15036      var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15037      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15038      var TOTAL_W=COLW*2+GAP;
15039      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15040      TREND_H=Math.min(Math.max(200,TREND_H),340);
15041      // Per-row chart heights (2-col grid)
15042      var gridRows=Math.ceil(gridCanvases.length/2);
15043      var rowHeights=[];
15044      for(var ri=0;ri<gridRows;ri++){{
15045        var rh=240;
15046        for(var ci=0;ci<2;ci++){{
15047          var cv=gridCanvases[ri*2+ci];
15048          if(cv&&cv.width>0){{
15049            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15050            rh=Math.max(rh,Math.min(420,nat));
15051          }}
15052        }}
15053        rowHeights.push(rh);
15054      }}
15055      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15056      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15057      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15058      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15059      var ctx=out.getContext('2d');
15060      var cs2=getComputedStyle(document.body);
15061      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15062      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15063      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15064
15065      // Background
15066      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15067
15068      // Orange header block
15069      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15070      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15071      ctx.fillText('Test Metrics — '+t.proj,22,42);
15072      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15073      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15074      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15075
15076      // Helper: draw a section title label
15077      function drawTitle(label, x, y, w) {{
15078        ctx.save();
15079        ctx.fillStyle=oxide;
15080        ctx.font='700 11px '+TM_FONT;
15081        ctx.textBaseline='middle';
15082        ctx.textAlign='left';
15083        ctx.letterSpacing='0.07em';
15084        ctx.fillText(label, x+2, y+TITLE_H/2);
15085        // Underline
15086        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15087        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15088        ctx.globalAlpha=1;
15089        ctx.restore();
15090      }}
15091
15092      var yOff=HEADER_H;
15093
15094      // Trend chart (full width)
15095      if(hasTrend){{
15096        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15097        yOff+=TITLE_H;
15098        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15099        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15100        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15101        ctx.drawImage(surf,0,yOff);
15102        yOff+=TREND_H+ROW_PAD;
15103      }}
15104
15105      // Grid charts (2-col), each cell gets title + chart
15106      for(var gi=0;gi<gridRows;gi++){{
15107        var rh2=rowHeights[gi];
15108        // Draw row titles and charts
15109        for(var gci=0;gci<2;gci++){{
15110          var idx2=gi*2+gci;
15111          if(idx2>=gridCanvases.length)continue;
15112          var gcv=gridCanvases[idx2];
15113          var gx=gci*(COLW+GAP);
15114          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15115        }}
15116        yOff+=TITLE_H;
15117        for(var gci2=0;gci2<2;gci2++){{
15118          var idx3=gi*2+gci2;
15119          if(idx3>=gridCanvases.length)continue;
15120          var gcv2=gridCanvases[idx3];
15121          var gx2=gci2*(COLW+GAP);
15122          var natW=gcv2.width,natH=gcv2.height;
15123          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15124          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15125          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15126          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15127          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15128          ctx.drawImage(surf2,gx2,yOff);
15129        }}
15130        yOff+=rh2+ROW_PAD;
15131      }}
15132
15133      // Dark footer
15134      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15135      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15136      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15137
15138      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15139      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15140    }}
15141
15142    function exportTmPDF() {{
15143      var D=currentLangTests;
15144      var t=tmExportMeta();
15145      var strips=document.querySelectorAll('.summary-strip');
15146      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15147      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15148      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15149      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15150      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15151      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15152      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15153      var rows='';
15154      (D||[]).forEach(function(d){{
15155        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15156          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15157          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15158          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15159          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15160          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15161          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15162      }});
15163      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15164        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15165        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15166        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15167        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15168        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15169        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15170      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>';
15171      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15172        +'html,body{{height:100%;margin:0;}}'
15173        +'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;}}'
15174        +'.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;}}'
15175        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15176        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15177        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15178        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15179        +'.rep-body{{padding:20px 32px;flex:1;}}'
15180        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15181        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15182        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15183        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15184        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15185        +'.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;}}'
15186        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15187        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15188        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15189        +'.n{{text-align:right;}}'
15190        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15191        +'.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;}}'
15192        +'</style>';
15193      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15194        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15195        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15196        +'<div class="rep-body">'+statsHtml
15197        +'<div class="section-hdr">Language Breakdown</div>'
15198        +tableHtml+'</div>'
15199        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15200        +'</body></html>';
15201      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15202      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:document.getElementById('tm-export-pdf-btn')}});
15203    }}
15204
15205    (function() {{
15206      var xBtn=document.getElementById('tm-export-xlsx-btn');
15207      var pngBtn=document.getElementById('tm-export-png-btn');
15208      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15209      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15210      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15211      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15212    }})();
15213
15214    applyScope();
15215  }})();
15216  </script>
15217  <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>
15218  {toast_assets}
15219</body>
15220</html>"#,
15221    );
15222    (
15223        [(axum::http::header::CACHE_CONTROL, "no-store")],
15224        Html(html),
15225    )
15226        .into_response()
15227}
15228
15229// ── Embeddable widget ─────────────────────────────────────────────────────────
15230// Protected. Returns a self-contained HTML page suitable for iframing inside
15231// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15232//
15233// GET /embed/summary?run_id=<uuid>&theme=dark
15234
15235#[derive(Deserialize)]
15236struct EmbedQuery {
15237    run_id: Option<String>,
15238    theme: Option<String>,
15239}
15240
15241async fn embed_handler(
15242    State(state): State<AppState>,
15243    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15244    Query(query): Query<EmbedQuery>,
15245) -> Response {
15246    let entry = {
15247        let reg = state.registry.lock().await;
15248        query.run_id.as_ref().map_or_else(
15249            || reg.entries.first().cloned(),
15250            |id| reg.find_by_run_id(id).cloned(),
15251        )
15252    };
15253
15254    let Some(entry) = entry else {
15255        return Html(
15256            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15257                .to_string(),
15258        )
15259        .into_response();
15260    };
15261
15262    let dark = query.theme.as_deref() == Some("dark");
15263    let languages: Vec<(String, u64, u64)> = entry
15264        .json_path
15265        .as_ref()
15266        .and_then(|p| read_json(p).ok())
15267        .map(|run| {
15268            run.totals_by_language
15269                .iter()
15270                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15271                .collect()
15272        })
15273        .unwrap_or_default();
15274
15275    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15276}
15277
15278fn render_embed_widget(
15279    entry: &RegistryEntry,
15280    languages: &[(String, u64, u64)],
15281    dark: bool,
15282    csp_nonce: &str,
15283) -> String {
15284    let s = &entry.summary;
15285    let total = s.code_lines + s.comment_lines + s.blank_lines;
15286    let code_pct = s
15287        .code_lines
15288        .checked_mul(100)
15289        .and_then(|n| n.checked_div(total))
15290        .unwrap_or(0);
15291
15292    let (bg, fg, surface, muted, border) = if dark {
15293        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15294    } else {
15295        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15296    };
15297
15298    let mut lang_rows = String::new();
15299    for (name, files, code) in languages {
15300        write!(
15301            lang_rows,
15302            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15303            escape_html(name),
15304            format_number(*files),
15305            format_number(*code),
15306        )
15307        .ok();
15308    }
15309
15310    let lang_table = if lang_rows.is_empty() {
15311        String::new()
15312    } else {
15313        format!(
15314            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15315        )
15316    };
15317
15318    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15319    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15320    let project_esc = escape_html(&entry.project_label);
15321    let code_lines = format_number(s.code_lines);
15322    let comment_lines = format_number(s.comment_lines);
15323    let files = format_number(s.files_analyzed);
15324    let code_raw = s.code_lines;
15325    let comment_raw = s.comment_lines;
15326    let blank_raw = s.blank_lines;
15327
15328    format!(
15329        r#"<!doctype html>
15330<html lang="en">
15331<head>
15332  <meta charset="utf-8">
15333  <meta name="viewport" content="width=device-width,initial-scale=1">
15334  <title>OxideSLOC &mdash; {project_esc}</title>
15335  <script src="/static/chart.js"></script>
15336  <style nonce="{csp_nonce}">
15337    *{{box-sizing:border-box;margin:0;padding:0}}
15338    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15339    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15340    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15341    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15342    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15343    .card .v{{font-size:18px;font-weight:700}}
15344    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15345    .row{{display:flex;gap:12px;align-items:flex-start}}
15346    .pie{{width:120px;height:120px;flex-shrink:0}}
15347    .lt{{border-collapse:collapse;width:100%;flex:1}}
15348    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15349    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15350    .n{{text-align:right}}
15351    .footer{{margin-top:10px;color:{muted};font-size:10px}}
15352  </style>
15353</head>
15354<body>
15355  <h2>{project_esc}</h2>
15356  <div class="sub">{timestamp} &middot; run {run_short}</div>
15357  <div class="cards">
15358    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15359    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15360    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15361    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15362  </div>
15363  <div class="row">
15364    <canvas class="pie" id="c"></canvas>
15365    {lang_table}
15366  </div>
15367  <div class="footer">oxide-sloc</div>
15368  <script nonce="{csp_nonce}">
15369    new Chart(document.getElementById('c'),{{
15370      type:'doughnut',
15371      data:{{
15372        labels:['Code','Comments','Blank'],
15373        datasets:[{{
15374          data:[{code_raw},{comment_raw},{blank_raw}],
15375          backgroundColor:['#4a78ee','#b35428','#aaa'],
15376          borderWidth:0
15377        }}]
15378      }},
15379      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15380    }});
15381  </script>
15382</body>
15383</html>"#
15384    )
15385}
15386
15387/// Returns a process-wide mutex unique to `dir`, so that two requests writing
15388/// artifacts into the *same* output directory (e.g. re-ingesting an identical
15389/// `run_id`) serialize instead of corrupting each other's files. Directories that
15390/// differ never contend, so legitimate parallel analyses keep their throughput.
15391fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15392    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15393        OnceLock::new();
15394    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15395    let mut guard = map
15396        .lock()
15397        .unwrap_or_else(std::sync::PoisonError::into_inner);
15398    guard
15399        .entry(dir.to_path_buf())
15400        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15401        .clone()
15402}
15403
15404#[allow(clippy::too_many_lines)]
15405fn persist_run_artifacts(
15406    run: &sloc_core::AnalysisRun,
15407    report_html: &str,
15408    run_dir: &Path,
15409    report_title: &str,
15410    file_stem: &str,
15411    result_context: RunResultContext,
15412) -> Result<(RunArtifacts, PendingPdf)> {
15413    // Serialize concurrent writers targeting this same output directory so their
15414    // file writes cannot interleave and corrupt one another.
15415    let dir_lock = output_dir_lock(run_dir);
15416    let _dir_guard = dir_lock
15417        .lock()
15418        .unwrap_or_else(std::sync::PoisonError::into_inner);
15419
15420    // Root dir + organised subdirectories.
15421    let html_dir = run_dir.join("html");
15422    let pdf_dir = run_dir.join("pdf");
15423    let excel_dir = run_dir.join("excel");
15424    let json_dir = run_dir.join("json");
15425    let submodules_dir = run_dir.join("submodules");
15426    for dir in &[
15427        run_dir,
15428        &html_dir,
15429        &pdf_dir,
15430        &excel_dir,
15431        &json_dir,
15432        &submodules_dir,
15433    ] {
15434        fs::create_dir_all(dir)
15435            .with_context(|| format!("failed to create directory {}", dir.display()))?;
15436    }
15437
15438    // HTML report in html/.
15439    let html_path = {
15440        let path = html_dir.join(format!("report_{file_stem}.html"));
15441        fs::write(&path, report_html)
15442            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15443        Some(path)
15444    };
15445
15446    // JSON result in json/.
15447    let json_path = {
15448        let path = json_dir.join(format!("result_{file_stem}.json"));
15449        let json = serde_json::to_string_pretty(run)
15450            .context("failed to serialize analysis run to JSON")?;
15451        fs::write(&path, json)
15452            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15453        Some(path)
15454    };
15455
15456    // PDF in pdf/.
15457    let (pdf_path, pending_pdf) = {
15458        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15459        match write_pdf_from_run(run, &pdf_dest) {
15460            Ok(()) => {
15461                eprintln!(
15462                    "[oxide-sloc][pdf] native PDF written to {}",
15463                    pdf_dest.display()
15464                );
15465                (Some(pdf_dest), None)
15466            }
15467            Err(native_err) => {
15468                eprintln!(
15469                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15470                );
15471                let source_html_path = html_path
15472                    .as_ref()
15473                    .expect("html_path always Some here")
15474                    .clone();
15475                let pending = Some((source_html_path, pdf_dest.clone(), false));
15476                (Some(pdf_dest), pending)
15477            }
15478        }
15479    };
15480
15481    // CSV and XLSX in excel/.
15482    let csv_path = {
15483        let path = excel_dir.join(format!("report_{file_stem}.csv"));
15484        if let Err(e) = sloc_report::write_csv(run, &path) {
15485            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15486            None
15487        } else {
15488            Some(path)
15489        }
15490    };
15491
15492    let xlsx_path = {
15493        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15494        if let Err(e) = sloc_report::write_xlsx(run, &path) {
15495            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15496            None
15497        } else {
15498            Some(path)
15499        }
15500    };
15501
15502    // Scan config in json/.
15503    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15504
15505    // Eagerly generate sub-reports before index.html so relative links work.
15506    if run.effective_configuration.discovery.submodule_breakdown {
15507        let run_id = &run.tool.run_id;
15508        for s in &run.submodule_summaries {
15509            build_submodule_row(s, run, run_id, run_dir);
15510        }
15511    }
15512
15513    // index.html at root — offline static export of the result-page dashboard.
15514    generate_offline_index(
15515        run,
15516        run_dir,
15517        file_stem,
15518        html_path.as_deref(),
15519        pdf_path.as_deref(),
15520        json_path.as_deref(),
15521        scan_config_path.as_deref(),
15522        &result_context,
15523    );
15524
15525    Ok((
15526        RunArtifacts {
15527            output_dir: run_dir.to_path_buf(),
15528            html_path,
15529            pdf_path,
15530            json_path,
15531            csv_path,
15532            xlsx_path,
15533            scan_config_path,
15534            report_title: report_title.to_string(),
15535            result_context,
15536        },
15537        pending_pdf,
15538    ))
15539}
15540
15541/// Render a static offline result-page dashboard and write it as `index.html` at
15542/// the root of the run output directory so business users can open it from disk.
15543#[allow(clippy::too_many_arguments)]
15544#[allow(clippy::too_many_lines)]
15545#[allow(clippy::similar_names)]
15546fn generate_offline_index(
15547    run: &sloc_core::AnalysisRun,
15548    run_dir: &Path,
15549    file_stem: &str,
15550    html_path: Option<&Path>,
15551    pdf_path: Option<&Path>,
15552    json_path: Option<&Path>,
15553    scan_config_path: Option<&Path>,
15554    result_context: &RunResultContext,
15555) {
15556    let prev_entry = &result_context.prev_entry;
15557    let prev_scan_count = result_context.prev_scan_count;
15558    let project_path = &result_context.project_path;
15559
15560    let scan_delta = prev_entry.as_ref().and_then(|prev| {
15561        prev.json_path
15562            .as_ref()
15563            .and_then(|p| read_json(p).ok())
15564            .map(|prev_run| compute_delta(&prev_run, run))
15565    });
15566
15567    let files_analyzed = run.per_file_records.len() as u64;
15568    let files_skipped = run.skipped_file_records.len() as u64;
15569    let totals = sum_lang_totals(run);
15570
15571    let DeltaFields {
15572        prev_fa_str,
15573        prev_fs_str,
15574        prev_pl_str,
15575        prev_cl_str,
15576        prev_cml_str,
15577        prev_bl_str,
15578        delta_fa_str,
15579        delta_fa_class,
15580        delta_fs_str,
15581        delta_fs_class,
15582        delta_pl_str,
15583        delta_pl_class,
15584        delta_cl_str,
15585        delta_cl_class,
15586        delta_cml_str,
15587        delta_cml_class,
15588        delta_bl_str,
15589        delta_bl_class,
15590        delta_lines_added,
15591        delta_lines_removed,
15592        delta_lines_net_str,
15593        delta_lines_net_class,
15594    } = compute_delta_fields(
15595        prev_entry.as_ref(),
15596        &totals,
15597        files_analyzed,
15598        files_skipped,
15599        scan_delta.as_ref(),
15600    );
15601
15602    let git_commit_url = git_commit_url_for(run);
15603    let git_branch_url = git_branch_url_for(run);
15604    let scan_performed_by = scan_performed_by(run);
15605
15606    // Convert absolute path to relative from run_dir (for file:// navigation).
15607    let make_rel = |p: Option<&Path>| -> Option<String> {
15608        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15609            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15610    };
15611
15612    let run_id = &run.tool.run_id;
15613
15614    // Submodule rows with relative paths into submodules/.
15615    let submodule_rows: Vec<SubmoduleRow> = run
15616        .submodule_summaries
15617        .iter()
15618        .map(|s| {
15619            let safe = sanitize_project_label(&s.name);
15620            let key = format!("sub_{safe}");
15621            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15622            SubmoduleRow {
15623                name: s.name.clone(),
15624                relative_path: s.relative_path.clone(),
15625                files_analyzed: s.files_analyzed,
15626                code_lines: s.code_lines,
15627                comment_lines: s.comment_lines,
15628                blank_lines: s.blank_lines,
15629                total_physical_lines: s.total_physical_lines,
15630                html_url: if sub_path.exists() {
15631                    Some(format!("submodules/{key}.html"))
15632                } else {
15633                    None
15634                },
15635            }
15636        })
15637        .collect();
15638
15639    let lang_chart_json = build_lang_chart_json(run);
15640
15641    let scan_config_rel =
15642        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15643
15644    let template = ResultTemplate {
15645        version: env!("CARGO_PKG_VERSION"),
15646        report_title: run.effective_configuration.reporting.report_title.clone(),
15647        project_path: project_path.clone(),
15648        output_dir: display_path(run_dir),
15649        run_id: run_id.clone(),
15650        run_id_short: run_id
15651            .split('-')
15652            .next_back()
15653            .unwrap_or(run_id)
15654            .chars()
15655            .take(7)
15656            .collect(),
15657        files_analyzed,
15658        files_skipped,
15659        physical_lines: totals.physical_lines,
15660        code_lines: totals.code_lines,
15661        comment_lines: totals.comment_lines,
15662        blank_lines: totals.blank_lines,
15663        mixed_lines: totals.mixed_lines,
15664        functions: totals.functions,
15665        classes: totals.classes,
15666        variables: totals.variables,
15667        imports: totals.imports,
15668        html_url: make_rel(html_path),
15669        pdf_url: make_rel(pdf_path),
15670        json_url: make_rel(json_path),
15671        html_download_url: make_rel(html_path),
15672        pdf_download_url: make_rel(pdf_path),
15673        json_download_url: make_rel(json_path),
15674        html_path: html_path.map(display_path),
15675        json_path: json_path.map(display_path),
15676        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15677        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15678        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15679        prev_fa_str,
15680        prev_fs_str,
15681        prev_pl_str,
15682        prev_cl_str,
15683        prev_cml_str,
15684        prev_bl_str,
15685        delta_fa_str,
15686        delta_fa_class,
15687        delta_fs_str,
15688        delta_fs_class,
15689        delta_pl_str,
15690        delta_pl_class,
15691        delta_cl_str,
15692        delta_cl_class,
15693        delta_cml_str,
15694        delta_cml_class,
15695        delta_bl_str,
15696        delta_bl_class,
15697        delta_lines_added,
15698        delta_lines_removed,
15699        delta_lines_net_str,
15700        delta_lines_net_class,
15701        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15702        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15703        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15704        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15705        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15706        git_branch: run.git_branch.clone(),
15707        git_branch_url,
15708        git_commit: run.git_commit_short.clone(),
15709        git_commit_long: run.git_commit_long.clone(),
15710        git_author: run.git_commit_author.clone(),
15711        git_commit_url,
15712        scan_performed_by,
15713        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
15714        os_display: format!(
15715            "{} / {}",
15716            run.environment.operating_system, run.environment.architecture
15717        ),
15718        test_count: run.summary_totals.test_count,
15719        test_assertion_count: run.summary_totals.test_assertion_count,
15720        current_scan_number: prev_scan_count + 1,
15721        prev_scan_count,
15722        submodule_rows,
15723        pdf_generating: false,
15724        scan_config_url: scan_config_rel,
15725        lang_chart_json,
15726        scatter_chart_json: build_scatter_chart_json(run),
15727        semantic_chart_json: build_semantic_chart_json(run),
15728        submodule_chart_json: build_submodule_chart_json(run),
15729        has_submodule_data: !run.submodule_summaries.is_empty(),
15730        has_semantic_data: run
15731            .totals_by_language
15732            .iter()
15733            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
15734        csp_nonce: String::new(),
15735        confluence_configured: false,
15736        server_mode: false,
15737        report_header_footer: run
15738            .effective_configuration
15739            .reporting
15740            .report_header_footer
15741            .clone(),
15742        is_offline: true,
15743        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
15744        lsloc: run.summary_totals.lsloc,
15745        uloc: run.uloc,
15746        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
15747        duplicate_group_count: run.duplicate_groups.len(),
15748        has_cocomo: run.cocomo.is_some(),
15749        cocomo_effort_str: run
15750            .cocomo
15751            .as_ref()
15752            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
15753        cocomo_duration_str: run
15754            .cocomo
15755            .as_ref()
15756            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
15757        cocomo_staff_str: run
15758            .cocomo
15759            .as_ref()
15760            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
15761        cocomo_ksloc_str: run
15762            .cocomo
15763            .as_ref()
15764            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
15765        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
15766            || "Organic".to_string(),
15767            |c| cocomo_mode_label(c.mode).to_string(),
15768        ),
15769        cocomo_mode_tooltip: run
15770            .cocomo
15771            .as_ref()
15772            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
15773        complexity_alert: 0,
15774        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
15775        cov_line_pct: cov_pct_str(
15776            run.summary_totals.coverage_lines_hit,
15777            run.summary_totals.coverage_lines_found,
15778        ),
15779        cov_fn_pct: cov_pct_str(
15780            run.summary_totals.coverage_functions_hit,
15781            run.summary_totals.coverage_functions_found,
15782        ),
15783        cov_branch_pct: cov_pct_str(
15784            run.summary_totals.coverage_branches_hit,
15785            run.summary_totals.coverage_branches_found,
15786        ),
15787        cov_lines_summary: cov_lines_summary_str(
15788            run.summary_totals.coverage_lines_hit,
15789            run.summary_totals.coverage_lines_found,
15790        ),
15791    };
15792
15793    if let Ok(html) = template.render() {
15794        // Inline the brand + watermark logos as data URIs: a file:// page has no
15795        // server to resolve the /images/logo/* routes, so without this the top-left
15796        // logo and the repeated "Oxide" background watermark render as broken images.
15797        let html = inline_offline_logos(&html);
15798        let index_path = run_dir.join("index.html");
15799        if let Err(e) = fs::write(&index_path, html) {
15800            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
15801        }
15802    }
15803}
15804
15805/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
15806/// offline `index.html` displays the brand logo and background watermark when
15807/// opened directly from disk (file://), where the `/images/...` routes do not exist.
15808fn inline_offline_logos(html: &str) -> String {
15809    use base64::Engine;
15810    let text_uri = format!(
15811        "data:image/png;base64,{}",
15812        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
15813    );
15814    let small_uri = format!(
15815        "data:image/png;base64,{}",
15816        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
15817    );
15818    html.replace("/images/logo/logo-text.png", &text_uri)
15819        .replace("/images/logo/small-logo.png", &small_uri)
15820}
15821
15822/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
15823/// then root (old flat layout), for backwards compatibility.
15824fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
15825    // New layout: json/scan-config_*.json
15826    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
15827        return Some(found);
15828    }
15829    // Old flat layout: scan-config.json or scan-config_*.json at root
15830    find_scan_config_in_dir_flat(dir)
15831}
15832
15833fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
15834    let exact = dir.join("scan-config.json");
15835    if exact.exists() {
15836        return Some(exact);
15837    }
15838    fs::read_dir(dir).ok().and_then(|entries| {
15839        entries
15840            .filter_map(std::result::Result::ok)
15841            .find(|e| {
15842                let name = e.file_name();
15843                let name = name.to_string_lossy();
15844                name.starts_with("scan-config") && name.ends_with(".json")
15845            })
15846            .map(|e| e.path())
15847    })
15848}
15849
15850// ── Config export / import ────────────────────────────────────────────────────
15851
15852/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
15853/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
15854#[derive(Deserialize)]
15855struct ExportPdfRequest {
15856    html: String,
15857    #[serde(default)]
15858    filename: Option<String>,
15859}
15860
15861async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
15862    let html_content = body.html;
15863    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
15864    if html_content.is_empty() {
15865        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
15866    }
15867    // Write HTML to a temp file, run headless Chrome PDF export, read result.
15868    let tmp_dir = std::env::temp_dir();
15869    let html_path = tmp_dir.join(format!(
15870        "sloc-export-{}.html",
15871        uuid::Uuid::new_v4().simple()
15872    ));
15873    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
15874    if let Err(e) = std::fs::write(&html_path, &html_content) {
15875        return (
15876            StatusCode::INTERNAL_SERVER_ERROR,
15877            format!("Failed to write temp HTML: {e}"),
15878        )
15879            .into_response();
15880    }
15881    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
15882    let _ = std::fs::remove_file(&html_path);
15883    if let Err(e) = pdf_result {
15884        let _ = std::fs::remove_file(&pdf_path);
15885        return (
15886            StatusCode::INTERNAL_SERVER_ERROR,
15887            format!("PDF generation failed: {e}"),
15888        )
15889            .into_response();
15890    }
15891    let pdf_bytes = match std::fs::read(&pdf_path) {
15892        Ok(b) => b,
15893        Err(e) => {
15894            let _ = std::fs::remove_file(&pdf_path);
15895            return (
15896                StatusCode::INTERNAL_SERVER_ERROR,
15897                format!("Failed to read PDF: {e}"),
15898            )
15899                .into_response();
15900        }
15901    };
15902    let _ = std::fs::remove_file(&pdf_path);
15903    let safe_name: String = filename
15904        .chars()
15905        .map(|c| {
15906            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
15907                c
15908            } else {
15909                '_'
15910            }
15911        })
15912        .collect();
15913    let disposition = format!("attachment; filename=\"{safe_name}\"");
15914    (
15915        [
15916            (header::CONTENT_TYPE, "application/pdf".to_string()),
15917            (header::CONTENT_DISPOSITION, disposition),
15918        ],
15919        pdf_bytes,
15920    )
15921        .into_response()
15922}
15923
15924async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
15925    let toml_str = match toml::to_string_pretty(&state.base_config) {
15926        Ok(s) => s,
15927        Err(e) => {
15928            return (
15929                StatusCode::INTERNAL_SERVER_ERROR,
15930                format!("serialization error: {e}"),
15931            )
15932                .into_response();
15933        }
15934    };
15935    (
15936        [
15937            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
15938            (
15939                header::CONTENT_DISPOSITION,
15940                "attachment; filename=\".oxide-sloc.toml\"",
15941            ),
15942        ],
15943        toml_str,
15944    )
15945        .into_response()
15946}
15947
15948#[derive(Serialize)]
15949struct OkResponse {
15950    ok: bool,
15951}
15952
15953#[derive(Serialize)]
15954struct SaveProfileResponse {
15955    ok: bool,
15956    id: String,
15957}
15958
15959#[derive(Serialize)]
15960struct ProfileListResponse {
15961    profiles: Vec<ScanProfile>,
15962}
15963
15964#[derive(Serialize)]
15965struct ImportConfigResponse {
15966    ok: bool,
15967    config: sloc_config::AppConfig,
15968}
15969
15970#[derive(Deserialize)]
15971struct ImportConfigBody {
15972    toml: String,
15973}
15974
15975async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
15976    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
15977        Ok(config) => {
15978            if let Err(e) = config.validate() {
15979                return error::unprocessable_entity(&e.to_string());
15980            }
15981            Json(ImportConfigResponse { ok: true, config }).into_response()
15982        }
15983        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
15984    }
15985}
15986
15987// ── Scan profiles API ─────────────────────────────────────────────────────────
15988
15989async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
15990    let store = state.scan_profiles.lock().await;
15991    Json(ProfileListResponse {
15992        profiles: store.profiles.clone(),
15993    })
15994}
15995
15996#[derive(Deserialize)]
15997struct SaveScanProfileBody {
15998    name: String,
15999    params: serde_json::Value,
16000}
16001
16002async fn api_save_scan_profile(
16003    State(state): State<AppState>,
16004    Json(body): Json<SaveScanProfileBody>,
16005) -> impl IntoResponse {
16006    if body.name.trim().is_empty() {
16007        return error::bad_request("name must not be empty");
16008    }
16009
16010    let id = uuid::Uuid::new_v4().to_string();
16011    let profile = ScanProfile {
16012        id: id.clone(),
16013        name: body.name.trim().to_string(),
16014        created_at: chrono::Utc::now().to_rfc3339(),
16015        params: body.params,
16016    };
16017
16018    let mut store = state.scan_profiles.lock().await;
16019    store.profiles.push(profile);
16020    if let Err(e) = store.save(&state.scan_profiles_path) {
16021        tracing::warn!("failed to persist scan profiles: {e}");
16022    }
16023    drop(store);
16024
16025    (
16026        StatusCode::CREATED,
16027        Json(SaveProfileResponse { ok: true, id }),
16028    )
16029        .into_response()
16030}
16031
16032async fn api_delete_scan_profile(
16033    State(state): State<AppState>,
16034    AxumPath(id): AxumPath<String>,
16035) -> impl IntoResponse {
16036    let mut store = state.scan_profiles.lock().await;
16037    let before = store.profiles.len();
16038    store.profiles.retain(|p| p.id != id);
16039    if store.profiles.len() == before {
16040        drop(store);
16041        return error::not_found("profile not found");
16042    }
16043    if let Err(e) = store.save(&state.scan_profiles_path) {
16044        tracing::warn!("failed to persist scan profiles: {e}");
16045    }
16046    drop(store);
16047    Json(OkResponse { ok: true }).into_response()
16048}
16049
16050fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16051    let value = raw.unwrap_or("out/web").trim();
16052    let path = if value.is_empty() {
16053        PathBuf::from("out/web")
16054    } else {
16055        PathBuf::from(value)
16056    };
16057
16058    if path.is_absolute() {
16059        path
16060    } else {
16061        workspace_root().join(path)
16062    }
16063}
16064
16065/// Derive the directory that holds remote-repo clones from the output root.
16066fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16067    std::env::var("SLOC_GIT_CLONES_DIR")
16068        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16069}
16070
16071/// Build a deterministic filesystem path for a cloned remote repository.
16072/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16073pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16074    let safe: String = repo_url
16075        .chars()
16076        .map(|c| {
16077            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16078                c
16079            } else {
16080                '_'
16081            }
16082        })
16083        .take(80)
16084        .collect();
16085    clones_dir.join(safe)
16086}
16087
16088/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16089/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16090pub(crate) fn scan_path_to_artifacts(
16091    scan_path: &Path,
16092    base_config: &AppConfig,
16093    label: &str,
16094) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16095    let mut config = base_config.clone();
16096    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16097    label.clone_into(&mut config.reporting.report_title);
16098    let run = analyze(&config, "git", None, None)?;
16099    let html = render_html(&run)?;
16100    let run_id = run.tool.run_id.clone();
16101    let project_label = sanitize_project_label(label);
16102    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16103    let file_stem = {
16104        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16105        if commit.is_empty() {
16106            project_label
16107        } else {
16108            format!("{project_label}_{commit}")
16109        }
16110    };
16111    let (artifacts, _pending_pdf) = persist_run_artifacts(
16112        &run,
16113        &html,
16114        &output_dir,
16115        label,
16116        &file_stem,
16117        RunResultContext::default(),
16118    )?;
16119    Ok((run_id, artifacts, run))
16120}
16121
16122/// Re-spawn background poll tasks for any polling schedules saved to disk.
16123async fn restart_poll_schedules(state: &AppState) {
16124    let store = state.schedules.lock().await;
16125    let poll_schedules: Vec<_> = store
16126        .schedules
16127        .iter()
16128        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16129        .cloned()
16130        .collect();
16131    drop(store);
16132    for schedule in poll_schedules {
16133        let interval = schedule.interval_secs.unwrap_or(300);
16134        let st = state.clone();
16135        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16136    }
16137}
16138
16139/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16140/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16141/// header (no HMAC over the body), so the token is exposed in cleartext unless
16142/// the transport is encrypted. This is only an advisory — TLS may be terminated
16143/// by an upstream reverse proxy, in which case the warning can be ignored.
16144async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16145    if state.tls_enabled {
16146        return;
16147    }
16148    let store = state.schedules.lock().await;
16149    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16150        s.kind == sloc_git::ScanScheduleKind::Webhook
16151            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16152    });
16153    drop(store);
16154    if has_gitlab_webhook {
16155        tracing::warn!(
16156            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16157             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16158             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16159             proxy so the token is not exposed in cleartext."
16160        );
16161    }
16162}
16163
16164fn split_patterns(raw: Option<&str>) -> Vec<String> {
16165    raw.unwrap_or("")
16166        .lines()
16167        .flat_map(|line| line.split(','))
16168        .map(str::trim)
16169        .filter(|part| !part.is_empty())
16170        .map(ToOwned::to_owned)
16171        .collect()
16172}
16173
16174#[must_use]
16175pub fn build_sub_run(
16176    parent: &AnalysisRun,
16177    sub: &sloc_core::SubmoduleSummary,
16178    parent_path: &str,
16179) -> AnalysisRun {
16180    let sub_files: Vec<_> = parent
16181        .per_file_records
16182        .iter()
16183        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16184        .cloned()
16185        .collect();
16186    let mut config = parent.effective_configuration.clone();
16187    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16188
16189    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16190    let mut functions = 0u64;
16191    let mut classes = 0u64;
16192    let mut variables = 0u64;
16193    let mut imports = 0u64;
16194    let mut test_count = 0u64;
16195    let mut test_assertion_count = 0u64;
16196    let mut test_suite_count = 0u64;
16197    let mut mixed_lines_separate = 0u64;
16198    let mut coverage_lines_found = 0u64;
16199    let mut coverage_lines_hit = 0u64;
16200    let mut coverage_functions_found = 0u64;
16201    let mut coverage_functions_hit = 0u64;
16202    let mut coverage_branches_found = 0u64;
16203    let mut coverage_branches_hit = 0u64;
16204    for r in &sub_files {
16205        functions += r.raw_line_categories.functions;
16206        classes += r.raw_line_categories.classes;
16207        variables += r.raw_line_categories.variables;
16208        imports += r.raw_line_categories.imports;
16209        test_count += r.raw_line_categories.test_count;
16210        test_assertion_count += r.raw_line_categories.test_assertion_count;
16211        test_suite_count += r.raw_line_categories.test_suite_count;
16212        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16213        if let Some(cov) = &r.coverage {
16214            coverage_lines_found += u64::from(cov.lines_found);
16215            coverage_lines_hit += u64::from(cov.lines_hit);
16216            coverage_functions_found += u64::from(cov.functions_found);
16217            coverage_functions_hit += u64::from(cov.functions_hit);
16218            coverage_branches_found += u64::from(cov.branches_found);
16219            coverage_branches_hit += u64::from(cov.branches_hit);
16220        }
16221    }
16222
16223    AnalysisRun {
16224        tool: parent.tool.clone(),
16225        environment: parent.environment.clone(),
16226        effective_configuration: config,
16227        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16228        summary_totals: SummaryTotals {
16229            files_considered: sub.files_analyzed,
16230            files_analyzed: sub.files_analyzed,
16231            files_skipped: 0,
16232            total_physical_lines: sub.total_physical_lines,
16233            code_lines: sub.code_lines,
16234            comment_lines: sub.comment_lines,
16235            blank_lines: sub.blank_lines,
16236            mixed_lines_separate,
16237            functions,
16238            classes,
16239            variables,
16240            imports,
16241            test_count,
16242            test_assertion_count,
16243            test_suite_count,
16244            coverage_lines_found,
16245            coverage_lines_hit,
16246            coverage_functions_found,
16247            coverage_functions_hit,
16248            coverage_branches_found,
16249            coverage_branches_hit,
16250            cyclomatic_complexity: 0,
16251            lsloc: None,
16252        },
16253        totals_by_language: sub.language_summaries.clone(),
16254        per_file_records: sub_files,
16255        skipped_file_records: vec![],
16256        warnings: vec![],
16257        submodule_summaries: vec![],
16258        git_commit_short: sub.git_commit_short.clone(),
16259        git_commit_long: sub.git_commit_long.clone(),
16260        git_branch: sub.git_branch.clone(),
16261        git_commit_author: sub.git_commit_author.clone(),
16262        git_commit_date: sub.git_commit_date.clone(),
16263        git_tags: None,
16264        git_nearest_tag: None,
16265        git_remote_url: sub.git_remote_url.clone(),
16266        style_summary: None,
16267        cocomo: None,
16268        uloc: 0,
16269        dryness_pct: None,
16270        duplicate_groups: vec![],
16271        duplicates_excluded: 0,
16272    }
16273}
16274
16275#[must_use]
16276pub fn sanitize_project_label(raw: &str) -> String {
16277    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16278    // where `Path` treats '\' as a literal character, not a separator.
16279    let candidate = raw
16280        .split(['/', '\\'])
16281        .rfind(|s| !s.is_empty())
16282        .unwrap_or("project");
16283
16284    let mut value = String::with_capacity(candidate.len());
16285    for ch in candidate.chars() {
16286        if ch.is_ascii_alphanumeric() {
16287            value.push(ch.to_ascii_lowercase());
16288        } else {
16289            value.push('-');
16290        }
16291    }
16292
16293    let compact = value.trim_matches('-').to_string();
16294    if compact.is_empty() {
16295        "project".to_string()
16296    } else {
16297        compact
16298    }
16299}
16300
16301/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16302/// comparisons with non-canonicalized stored paths work correctly.
16303fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16304    let s = path.to_string_lossy();
16305    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16306        return PathBuf::from(format!(r"\\{rest}"));
16307    }
16308    if let Some(rest) = s.strip_prefix(r"\\?\") {
16309        return PathBuf::from(rest);
16310    }
16311    path
16312}
16313
16314/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16315/// commit page URL for the most common hosting platforms.
16316fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16317    let base = if let Some(rest) = remote.strip_prefix("git@") {
16318        let (host, path) = rest.split_once(':')?;
16319        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16320    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16321        remote
16322            .trim_end_matches('/')
16323            .trim_end_matches(".git")
16324            .to_owned()
16325    } else {
16326        return None;
16327    };
16328    let base = base.trim_end_matches('/');
16329    // GitLab uses /-/commit/; everything else uses /commit/
16330    if base.contains("gitlab.com") || base.contains("gitlab.") {
16331        Some(format!("{base}/-/commit/{sha}"))
16332    } else if base.contains("bitbucket.org") {
16333        Some(format!("{base}/commits/{sha}"))
16334    } else {
16335        Some(format!("{base}/commit/{sha}"))
16336    }
16337}
16338
16339/// Convert a git remote URL (https or git@) + branch name into a browser-openable
16340/// branch page URL for the most common hosting platforms.
16341fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16342    let base = if let Some(rest) = remote.strip_prefix("git@") {
16343        let (host, path) = rest.split_once(':')?;
16344        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16345    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16346        remote
16347            .trim_end_matches('/')
16348            .trim_end_matches(".git")
16349            .to_owned()
16350    } else {
16351        return None;
16352    };
16353    let base = base.trim_end_matches('/');
16354    if base.contains("gitlab.com") || base.contains("gitlab.") {
16355        Some(format!("{base}/-/tree/{branch}"))
16356    } else {
16357        Some(format!("{base}/tree/{branch}"))
16358    }
16359}
16360
16361fn display_path(path: &Path) -> String {
16362    let s = path.to_string_lossy();
16363    // Strip Windows extended-length prefix for display only; the underlying
16364    // PathBuf remains unchanged so file operations are unaffected.
16365    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
16366    // \\?\C:\path           →  C:\path          (local drive)
16367    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16368        return format!(r"\\{rest}");
16369    }
16370    if let Some(rest) = s.strip_prefix(r"\\?\") {
16371        return rest.to_owned();
16372    }
16373    s.into_owned()
16374}
16375
16376fn sanitize_path_str(s: &str) -> String {
16377    // Forward-slash variants of the Windows extended-length prefix that appear
16378    // when paths stored as plain strings have been processed through some path
16379    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
16380    if let Some(rest) = s.strip_prefix("//?/UNC/") {
16381        return format!("//{rest}");
16382    }
16383    if let Some(rest) = s.strip_prefix("//?/") {
16384        return rest.to_owned();
16385    }
16386    display_path(Path::new(s))
16387}
16388
16389fn workspace_root() -> PathBuf {
16390    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
16391    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16392        let p = PathBuf::from(root);
16393        if p.is_dir() {
16394            return p;
16395        }
16396    }
16397
16398    // Current working directory — works for `cargo run` from the project root
16399    // and for scripts/run.sh which cds there first.
16400    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16401}
16402
16403/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
16404fn make_git_label(repo: &str, ref_name: &str) -> String {
16405    if repo.is_empty() || ref_name.is_empty() {
16406        return String::new();
16407    }
16408    let base = repo
16409        .trim_end_matches('/')
16410        .trim_end_matches(".git")
16411        .rsplit('/')
16412        .next()
16413        .unwrap_or("repo");
16414    let ref_safe: String = ref_name
16415        .chars()
16416        .map(|c| {
16417            if c.is_alphanumeric() || c == '-' || c == '.' {
16418                c
16419            } else {
16420                '_'
16421            }
16422        })
16423        .collect();
16424    format!("{base}_at_{ref_safe}_sloc")
16425}
16426
16427/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
16428fn desktop_dir() -> PathBuf {
16429    if let Ok(profile) = std::env::var("USERPROFILE") {
16430        let p = PathBuf::from(profile).join("Desktop");
16431        if p.exists() {
16432            return p;
16433        }
16434    }
16435    if let Ok(home) = std::env::var("HOME") {
16436        let p = PathBuf::from(home).join("Desktop");
16437        if p.exists() {
16438            return p;
16439        }
16440    }
16441    workspace_root().join("out").join("web")
16442}
16443
16444fn resolve_input_path(raw: &str) -> PathBuf {
16445    let trimmed = raw.trim();
16446    if trimmed.is_empty() {
16447        return workspace_root().join("samples").join("basic");
16448    }
16449
16450    let candidate = PathBuf::from(trimmed);
16451    let resolved = if candidate.is_absolute() {
16452        candidate
16453    } else {
16454        let rooted = workspace_root().join(&candidate);
16455        if rooted.exists() {
16456            rooted
16457        } else {
16458            workspace_root().join(candidate)
16459        }
16460    };
16461
16462    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
16463    // strip that prefix so stored paths and the displayed "Project path" are clean.
16464    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16465    PathBuf::from(display_path(&canonical))
16466}
16467
16468fn dir_size_bytes(path: &Path) -> u64 {
16469    let mut total = 0u64;
16470    if let Ok(rd) = fs::read_dir(path) {
16471        for entry in rd.filter_map(Result::ok) {
16472            let p = entry.path();
16473            if p.is_file() {
16474                if let Ok(meta) = p.metadata() {
16475                    total += meta.len();
16476                }
16477            } else if p.is_dir() {
16478                total += dir_size_bytes(&p);
16479            }
16480        }
16481    }
16482    total
16483}
16484
16485#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
16486fn format_dir_size(bytes: u64) -> String {
16487    if bytes >= 1_073_741_824 {
16488        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16489    } else if bytes >= 1_048_576 {
16490        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16491    } else if bytes >= 1_024 {
16492        format!("{:.0} KB", bytes as f64 / 1_024.0)
16493    } else {
16494        format!("{bytes} B")
16495    }
16496}
16497
16498fn render_submodule_chips(
16499    root: &Path,
16500    submodules: &[(String, std::path::PathBuf)],
16501    out: &mut String,
16502) {
16503    use std::fmt::Write as _;
16504    let count = submodules.len();
16505    out.push_str(r#"<div class="submodule-preview-strip">"#);
16506    write!(
16507        out,
16508        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>"#,
16509        if count == 1 { "" } else { "s" }
16510    )
16511    .ok();
16512    out.push_str(r#"<div class="submodule-preview-chips">"#);
16513    for (sub_name, sub_rel_path) in submodules {
16514        let sub_abs = root.join(sub_rel_path);
16515        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16516        let mut sub_stats = PreviewStats::default();
16517        let mut sub_rows: Vec<PreviewRow> = Vec::new();
16518        let mut sub_langs: Vec<&'static str> = Vec::new();
16519        let mut sub_budget = PreviewBudget {
16520            shown: 0,
16521            max_entries: 2000,
16522            max_depth: 9,
16523        };
16524        let mut sub_next_id = 1usize;
16525        let _ = collect_preview_rows(
16526            &sub_abs,
16527            &sub_abs,
16528            0,
16529            None,
16530            &mut sub_next_id,
16531            &mut sub_budget,
16532            &mut sub_stats,
16533            &mut sub_rows,
16534            &mut sub_langs,
16535            &[],
16536            &[],
16537        );
16538        let stats_json = format!(
16539            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16540            sub_stats.directories,
16541            sub_stats.files,
16542            sub_stats.supported,
16543            sub_stats.skipped,
16544            sub_stats.unsupported
16545        );
16546        write!(
16547            out,
16548            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>"#,
16549            escape_html(sub_name),
16550            escape_html(&sub_rel_path.to_string_lossy()),
16551            escape_html(&sub_size),
16552            escape_html(&stats_json),
16553            escape_html(sub_name),
16554            escape_html(&sub_size),
16555        )
16556        .ok();
16557    }
16558    out.push_str(
16559        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
16560    );
16561    out.push_str(r"</div>");
16562}
16563
16564fn render_language_pills_row(languages: &[&str], out: &mut String) {
16565    use std::fmt::Write as _;
16566    if languages.is_empty() {
16567        out.push_str(
16568            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16569        );
16570        return;
16571    }
16572    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16573    for language in languages {
16574        if let Some(icon) = language_icon_file(language) {
16575            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();
16576        } else if let Some(svg) = language_inline_svg(language) {
16577            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();
16578        } else {
16579            write!(
16580                out,
16581                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16582                escape_html(&language.to_ascii_lowercase()),
16583                escape_html(language)
16584            )
16585            .ok();
16586        }
16587    }
16588}
16589
16590#[allow(clippy::too_many_lines)]
16591fn build_preview_html(
16592    root: &Path,
16593    include_patterns: &[String],
16594    exclude_patterns: &[String],
16595) -> Result<String> {
16596    if !root.exists() {
16597        return Ok(format!(
16598            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16599            escape_html(&display_path(root))
16600        ));
16601    }
16602
16603    let _selected = display_path(root);
16604    let mut stats = PreviewStats::default();
16605    let mut rows = Vec::new();
16606    let mut languages = Vec::new();
16607    let mut budget = PreviewBudget {
16608        shown: 0,
16609        max_entries: 600,
16610        max_depth: 9,
16611    };
16612    let mut next_row_id = 1usize;
16613
16614    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16615        || root.to_string_lossy().into_owned(),
16616        std::string::ToString::to_string,
16617    );
16618    let root_modified = root
16619        .metadata()
16620        .ok()
16621        .and_then(|meta| meta.modified().ok())
16622        .map_or_else(|| "-".to_string(), format_system_time);
16623
16624    rows.push(PreviewRow {
16625        row_id: 0,
16626        parent_row_id: None,
16627        depth: 0,
16628        name: format!("{root_name}/"),
16629        kind: PreviewKind::Dir,
16630        is_dir: true,
16631        language: None,
16632        modified: root_modified,
16633        type_label: "Directory".to_string(),
16634    });
16635    collect_preview_rows(
16636        root,
16637        root,
16638        0,
16639        Some(0),
16640        &mut next_row_id,
16641        &mut budget,
16642        &mut stats,
16643        &mut rows,
16644        &mut languages,
16645        include_patterns,
16646        exclude_patterns,
16647    )?;
16648
16649    let root_size = format_dir_size(dir_size_bytes(root));
16650
16651    let mut out = String::new();
16652    write!(
16653        out,
16654        r#"<div class="explorer-wrap" data-project-size="{}">"#,
16655        escape_html(&root_size)
16656    )
16657    .ok();
16658    out.push_str(r#"<div class="explorer-toolbar compact">"#);
16659    out.push_str(r#"<div class="explorer-title-group">"#);
16660    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16661    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16662    out.push_str(r"</div></div>");
16663
16664    out.push_str(r#"<div class="scope-stats">"#);
16665    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();
16666    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();
16667    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();
16668    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();
16669    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();
16670    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>"#);
16671    out.push_str(r"</div>");
16672
16673    let submodules = sloc_core::detect_submodules(root);
16674    if !submodules.is_empty() {
16675        render_submodule_chips(root, &submodules, &mut out);
16676    }
16677
16678    out.push_str(r#"<div class="scope-info-row">"#);
16679    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16680    render_language_pills_row(&languages, &mut out);
16681    out.push_str(r"</div></div>");
16682    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>"#);
16683    out.push_str(r"</div>");
16684
16685    out.push_str(r#"<div class="file-explorer-shell">"#);
16686    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>"#);
16687    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>"#);
16688    out.push_str(r#"<div class="file-explorer-tree">"#);
16689    for row in rows {
16690        let status_label = row.kind.label();
16691        let lang_attr = row.language.unwrap_or("");
16692        let toggle_html = if row.is_dir {
16693            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16694                .to_string()
16695        } else {
16696            r#"<span class="tree-bullet">•</span>"#.to_string()
16697        };
16698        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();
16699    }
16700    if budget.shown >= budget.max_entries {
16701        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>"#);
16702    }
16703    out.push_str(r"</div></div></div>");
16704
16705    Ok(out)
16706}
16707
16708#[derive(Default)]
16709struct PreviewStats {
16710    directories: usize,
16711    files: usize,
16712    supported: usize,
16713    skipped: usize,
16714    unsupported: usize,
16715}
16716
16717struct PreviewRow {
16718    row_id: usize,
16719    parent_row_id: Option<usize>,
16720    depth: usize,
16721    name: String,
16722    kind: PreviewKind,
16723    is_dir: bool,
16724    language: Option<&'static str>,
16725    modified: String,
16726    type_label: String,
16727}
16728
16729#[derive(Copy, Clone)]
16730enum PreviewKind {
16731    Dir,
16732    Supported,
16733    Skipped,
16734    Unsupported,
16735}
16736
16737impl PreviewKind {
16738    const fn filter_key(self) -> &'static str {
16739        match self {
16740            Self::Dir => "dir",
16741            Self::Supported => "supported",
16742            Self::Skipped => "skipped",
16743            Self::Unsupported => "unsupported",
16744        }
16745    }
16746
16747    const fn label(self) -> &'static str {
16748        match self {
16749            Self::Dir => "dir",
16750            Self::Supported => "supported",
16751            Self::Skipped => "skipped by policy",
16752            Self::Unsupported => "unsupported",
16753        }
16754    }
16755
16756    const fn badge_class(self) -> &'static str {
16757        match self {
16758            Self::Dir => "badge badge-dir",
16759            Self::Supported => "badge badge-scan",
16760            Self::Skipped => "badge badge-skip",
16761            Self::Unsupported => "badge badge-unsupported",
16762        }
16763    }
16764
16765    const fn node_class(self) -> &'static str {
16766        match self {
16767            Self::Dir => "tree-node-dir",
16768            Self::Supported => "tree-node-supported",
16769            Self::Skipped => "tree-node-skipped",
16770            Self::Unsupported => "tree-node-unsupported",
16771        }
16772    }
16773}
16774
16775struct PreviewBudget {
16776    shown: usize,
16777    max_entries: usize,
16778    max_depth: usize,
16779}
16780
16781/// Handle a single directory entry inside `collect_preview_rows`.
16782/// Returns `true` when the entry was handled (caller should `continue`).
16783#[allow(clippy::too_many_arguments)]
16784fn handle_preview_dir_entry(
16785    root: &Path,
16786    path: &Path,
16787    name: &str,
16788    modified: String,
16789    depth: usize,
16790    parent_row_id: Option<usize>,
16791    row_id: usize,
16792    next_row_id: &mut usize,
16793    budget: &mut PreviewBudget,
16794    stats: &mut PreviewStats,
16795    rows: &mut Vec<PreviewRow>,
16796    languages: &mut Vec<&'static str>,
16797    include_patterns: &[String],
16798    exclude_patterns: &[String],
16799) -> Result<()> {
16800    let relative = preview_relative_path(root, path);
16801    if should_skip_preview_directory(&relative, exclude_patterns) {
16802        return Ok(());
16803    }
16804    stats.directories += 1;
16805    rows.push(PreviewRow {
16806        row_id,
16807        parent_row_id,
16808        depth: depth + 1,
16809        name: format!("{name}/"),
16810        kind: PreviewKind::Dir,
16811        is_dir: true,
16812        language: None,
16813        modified,
16814        type_label: "Directory".to_string(),
16815    });
16816    budget.shown += 1;
16817    if !matches!(name, ".git" | "node_modules" | "target") {
16818        collect_preview_rows(
16819            root,
16820            path,
16821            depth + 1,
16822            Some(row_id),
16823            next_row_id,
16824            budget,
16825            stats,
16826            rows,
16827            languages,
16828            include_patterns,
16829            exclude_patterns,
16830        )?;
16831    }
16832    Ok(())
16833}
16834
16835/// Handle a single file entry inside `collect_preview_rows`.
16836#[allow(clippy::too_many_arguments)]
16837fn handle_preview_file_entry(
16838    root: &Path,
16839    path: &Path,
16840    name: &str,
16841    modified: String,
16842    depth: usize,
16843    parent_row_id: Option<usize>,
16844    row_id: usize,
16845    budget: &mut PreviewBudget,
16846    stats: &mut PreviewStats,
16847    rows: &mut Vec<PreviewRow>,
16848    languages: &mut Vec<&'static str>,
16849    include_patterns: &[String],
16850    exclude_patterns: &[String],
16851) {
16852    let relative = preview_relative_path(root, path);
16853    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16854        return;
16855    }
16856    stats.files += 1;
16857    let kind = classify_preview_file(name);
16858    match kind {
16859        PreviewKind::Supported => stats.supported += 1,
16860        PreviewKind::Skipped => stats.skipped += 1,
16861        PreviewKind::Unsupported => stats.unsupported += 1,
16862        PreviewKind::Dir => {}
16863    }
16864    let language = detect_language_name(name);
16865    if let Some(lang) = language {
16866        if !languages.contains(&lang) {
16867            languages.push(lang);
16868        }
16869    }
16870    rows.push(PreviewRow {
16871        row_id,
16872        parent_row_id,
16873        depth: depth + 1,
16874        name: name.to_owned(),
16875        kind,
16876        is_dir: false,
16877        language,
16878        modified,
16879        type_label: preview_type_label(name, language, kind),
16880    });
16881    budget.shown += 1;
16882}
16883
16884#[allow(clippy::too_many_arguments)]
16885#[allow(clippy::too_many_lines)]
16886fn collect_preview_rows(
16887    root: &Path,
16888    dir: &Path,
16889    depth: usize,
16890    parent_row_id: Option<usize>,
16891    next_row_id: &mut usize,
16892    budget: &mut PreviewBudget,
16893    stats: &mut PreviewStats,
16894    rows: &mut Vec<PreviewRow>,
16895    languages: &mut Vec<&'static str>,
16896    include_patterns: &[String],
16897    exclude_patterns: &[String],
16898) -> Result<()> {
16899    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16900        return Ok(());
16901    }
16902
16903    let mut entries = fs::read_dir(dir)
16904        .with_context(|| format!("failed to read directory {}", dir.display()))?
16905        .filter_map(std::result::Result::ok)
16906        .collect::<Vec<_>>();
16907    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16908
16909    for entry in entries {
16910        if budget.shown >= budget.max_entries {
16911            break;
16912        }
16913
16914        let path = entry.path();
16915        let name = entry.file_name().to_string_lossy().into_owned();
16916        let Ok(metadata) = entry.metadata() else {
16917            continue;
16918        };
16919        let row_id = *next_row_id;
16920        *next_row_id += 1;
16921        let modified = metadata
16922            .modified()
16923            .ok()
16924            .map_or_else(|| "-".to_string(), format_system_time);
16925
16926        if metadata.is_dir() {
16927            handle_preview_dir_entry(
16928                root,
16929                &path,
16930                &name,
16931                modified,
16932                depth,
16933                parent_row_id,
16934                row_id,
16935                next_row_id,
16936                budget,
16937                stats,
16938                rows,
16939                languages,
16940                include_patterns,
16941                exclude_patterns,
16942            )?;
16943            continue;
16944        }
16945
16946        if metadata.is_file() {
16947            handle_preview_file_entry(
16948                root,
16949                &path,
16950                &name,
16951                modified,
16952                depth,
16953                parent_row_id,
16954                row_id,
16955                budget,
16956                stats,
16957                rows,
16958                languages,
16959                include_patterns,
16960                exclude_patterns,
16961            );
16962        }
16963    }
16964
16965    Ok(())
16966}
16967
16968fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
16969    if let Some(language) = language {
16970        return format!("{language} source");
16971    }
16972    let lower = name.to_ascii_lowercase();
16973    let ext = Path::new(&lower)
16974        .extension()
16975        .and_then(|e| e.to_str())
16976        .unwrap_or("");
16977    match kind {
16978        PreviewKind::Skipped => {
16979            if lower.ends_with(".min.js") {
16980                "Minified asset".to_string()
16981            } else if [
16982                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
16983            ]
16984            .contains(&ext)
16985            {
16986                "Binary or archive".to_string()
16987            } else {
16988                "Skipped file".to_string()
16989            }
16990        }
16991        PreviewKind::Unsupported => {
16992            if ext.is_empty() {
16993                "Unsupported file".to_string()
16994            } else {
16995                format!("{} file", ext.to_ascii_uppercase())
16996            }
16997        }
16998        PreviewKind::Supported => "Supported source".to_string(),
16999        PreviewKind::Dir => "Directory".to_string(),
17000    }
17001}
17002
17003fn format_system_time(time: SystemTime) -> String {
17004    #[allow(clippy::cast_possible_wrap)]
17005    let secs = match time.duration_since(UNIX_EPOCH) {
17006        Ok(duration) => duration.as_secs() as i64,
17007        Err(_) => return "-".to_string(),
17008    };
17009    let days = secs.div_euclid(86_400);
17010    let secs_of_day = secs.rem_euclid(86_400);
17011    let (year, month, day) = civil_from_days(days);
17012    let hour = secs_of_day / 3_600;
17013    let minute = (secs_of_day % 3_600) / 60;
17014    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17015}
17016
17017#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17018fn civil_from_days(days: i64) -> (i32, u32, u32) {
17019    let z = days + 719_468;
17020    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17021    let doe = z - era * 146_097;
17022    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17023    let y = yoe + era * 400;
17024    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17025    let mp = (5 * doy + 2) / 153;
17026    let d = doy - (153 * mp + 2) / 5 + 1;
17027    let m = mp + if mp < 10 { 3 } else { -9 };
17028    let year = y + i64::from(m <= 2);
17029    (year as i32, m as u32, d as u32)
17030}
17031
17032// The input is already lowercased via `to_ascii_lowercase()` before calling
17033// `ends_with`, so the comparisons are inherently case-insensitive.
17034#[allow(clippy::case_sensitive_file_extension_comparisons)]
17035fn detect_language_name(name: &str) -> Option<&'static str> {
17036    let lower = name.to_ascii_lowercase();
17037    if lower.ends_with(".c") || lower.ends_with(".h") {
17038        Some("C")
17039    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17040        .iter()
17041        .any(|s| lower.ends_with(s))
17042    {
17043        Some("C++")
17044    } else if lower.ends_with(".cs") {
17045        Some("C#")
17046    } else if lower.ends_with(".py") {
17047        Some("Python")
17048    } else if lower.ends_with(".sh") {
17049        Some("Shell")
17050    } else if [".ps1", ".psm1", ".psd1"]
17051        .iter()
17052        .any(|s| lower.ends_with(s))
17053    {
17054        Some("PowerShell")
17055    } else {
17056        None
17057    }
17058}
17059
17060fn language_icon_file(language: &str) -> Option<&'static str> {
17061    match language {
17062        "C" => Some("c.png"),
17063        "C++" => Some("cpp.png"),
17064        "C#" => Some("c-sharp.png"),
17065        "Python" => Some("python.png"),
17066        "Shell" => Some("shell.png"),
17067        "PowerShell" => Some("powershell.png"),
17068        "JavaScript" => Some("java-script.png"),
17069        "HTML" => Some("html-5.png"),
17070        "Java" => Some("java.png"),
17071        "Visual Basic" => Some("visual-basic.png"),
17072        "Assembly" => Some("asm.png"),
17073        "Go" => Some("go.png"),
17074        "R" => Some("r.png"),
17075        "XML" => Some("xml.png"),
17076        "Groovy" => Some("groovy.png"),
17077        "Dockerfile" => Some("docker.png"),
17078        "Makefile" => Some("makefile.svg"),
17079        "Perl" => Some("perl.svg"),
17080        _ => None,
17081    }
17082}
17083
17084// Inline SVG badges for languages that have no PNG icon in images/icons/.
17085// Using inline SVG keeps the web UI fully self-contained — no extra files
17086// needed on disk, no 404s on air-gapped deployments.
17087// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17088fn language_inline_svg(language: &str) -> Option<&'static str> {
17089    match language {
17090        "Rust" => Some(
17091            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>"##,
17092        ),
17093        "TypeScript" => Some(
17094            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>"##,
17095        ),
17096        _ => None,
17097    }
17098}
17099
17100// The input is already lowercased via `to_ascii_lowercase()` before the
17101// `ends_with` calls, so these comparisons are inherently case-insensitive.
17102#[allow(clippy::case_sensitive_file_extension_comparisons)]
17103fn classify_preview_file(name: &str) -> PreviewKind {
17104    let lower = name.to_ascii_lowercase();
17105
17106    let scannable = [
17107        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17108        ".psm1", ".psd1",
17109    ]
17110    .iter()
17111    .any(|suffix| lower.ends_with(suffix));
17112
17113    if scannable {
17114        PreviewKind::Supported
17115    } else if lower.ends_with(".min.js")
17116        || lower.ends_with(".lock")
17117        || lower.ends_with(".png")
17118        || lower.ends_with(".jpg")
17119        || lower.ends_with(".jpeg")
17120        || lower.ends_with(".gif")
17121        || lower.ends_with(".zip")
17122        || lower.ends_with(".pdf")
17123        || lower.ends_with(".pyc")
17124        || lower.ends_with(".xz")
17125        || lower.ends_with(".tar")
17126        || lower.ends_with(".gz")
17127    {
17128        PreviewKind::Skipped
17129    } else {
17130        PreviewKind::Unsupported
17131    }
17132}
17133
17134fn preview_relative_path(root: &Path, path: &Path) -> String {
17135    path.strip_prefix(root)
17136        .ok()
17137        .unwrap_or(path)
17138        .to_string_lossy()
17139        .replace('\\', "/")
17140        .trim_matches('/')
17141        .to_string()
17142}
17143
17144fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17145    if relative.is_empty() {
17146        return false;
17147    }
17148
17149    exclude_patterns.iter().any(|pattern| {
17150        wildcard_match(pattern, relative)
17151            || wildcard_match(pattern, &format!("{relative}/"))
17152            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17153    })
17154}
17155
17156fn should_include_preview_file(
17157    relative: &str,
17158    include_patterns: &[String],
17159    exclude_patterns: &[String],
17160) -> bool {
17161    if relative.is_empty() {
17162        return true;
17163    }
17164
17165    let included = include_patterns.is_empty()
17166        || include_patterns
17167            .iter()
17168            .any(|pattern| wildcard_match(pattern, relative));
17169    let excluded = exclude_patterns
17170        .iter()
17171        .any(|pattern| wildcard_match(pattern, relative));
17172
17173    included && !excluded
17174}
17175
17176fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17177    let pattern = pattern.trim().replace('\\', "/");
17178    let candidate = candidate.trim().replace('\\', "/");
17179    let p = pattern.as_bytes();
17180    let c = candidate.as_bytes();
17181    let mut pi = 0usize;
17182    let mut ci = 0usize;
17183    let mut star: Option<usize> = None;
17184    let mut star_match = 0usize;
17185
17186    while ci < c.len() {
17187        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17188            pi += 1;
17189            ci += 1;
17190        } else if pi < p.len() && p[pi] == b'*' {
17191            while pi < p.len() && p[pi] == b'*' {
17192                pi += 1;
17193            }
17194            star = Some(pi);
17195            star_match = ci;
17196        } else if let Some(star_pi) = star {
17197            star_match += 1;
17198            ci = star_match;
17199            pi = star_pi;
17200        } else {
17201            return false;
17202        }
17203    }
17204
17205    while pi < p.len() && p[pi] == b'*' {
17206        pi += 1;
17207    }
17208
17209    pi == p.len()
17210}
17211
17212fn escape_html(value: &str) -> String {
17213    value
17214        .replace('&', "&amp;")
17215        .replace('<', "&lt;")
17216        .replace('>', "&gt;")
17217        .replace('"', "&quot;")
17218        .replace('\'', "&#39;")
17219}
17220
17221#[derive(Clone)]
17222struct SubmoduleRow {
17223    name: String,
17224    relative_path: String,
17225    files_analyzed: u64,
17226    code_lines: u64,
17227    comment_lines: u64,
17228    blank_lines: u64,
17229    total_physical_lines: u64,
17230    html_url: Option<String>,
17231}
17232
17233#[derive(Template)]
17234#[template(
17235    source = r##"
17236<!doctype html>
17237<html lang="en">
17238<head>
17239  <meta charset="utf-8">
17240  <title>OxideSLOC | tmp-sloc</title>
17241  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17242  <style nonce="{{ csp_nonce }}">
17243    :root {
17244      --bg: #efe9e2;
17245      --surface: #fcfaf7;
17246      --surface-2: #f7f0e8;
17247      --surface-3: #efe3d5;
17248      --line: #dfcfbf;
17249      --line-strong: #cfb29c;
17250      --text: #2f241c;
17251      --muted: #6f6257;
17252      --muted-2: #917f71;
17253      --nav: #b85d33;
17254      --nav-2: #7a371b;
17255      --accent: #2563eb;
17256      --accent-2: #1d4ed8;
17257      --oxide: #b85d33;
17258      --oxide-2: #8f4220;
17259      --success-bg: #eaf9ee;
17260      --success-text: #1c8746;
17261      --warn-bg: #fff2d8;
17262      --warn-text: #926000;
17263      --danger-bg: #fdeaea;
17264      --danger-text: #b33b3b;
17265      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17266      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17267      --radius: 14px;
17268    }
17269
17270    body.dark-theme {
17271      --bg: #1b1511;
17272      --surface: #261c17;
17273      --surface-2: #2d221d;
17274      --surface-3: #372922;
17275      --line: #524238;
17276      --line-strong: #6c5649;
17277      --text: #f5ece6;
17278      --muted: #c7b7aa;
17279      --muted-2: #aa9485;
17280      --nav: #b85d33;
17281      --nav-2: #7a371b;
17282      --accent: #6f9bff;
17283      --accent-2: #4a78ee;
17284      --oxide: #d37a4c;
17285      --oxide-2: #b35428;
17286      --success-bg: #163927;
17287      --success-text: #8fe2a8;
17288      --warn-bg: #3c2d11;
17289      --warn-text: #f3cb75;
17290      --danger-bg: #3d1f1f;
17291      --danger-text: #ff9f9f;
17292      --shadow: 0 14px 28px rgba(0,0,0,0.28);
17293      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17294    }
17295
17296    * { box-sizing: border-box; }
17297    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); }
17298    html { overflow-y: scroll; }
17299    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17300    .top-nav, .page, .loading { position: relative; z-index: 2; }
17301    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17302    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17303    .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); }
17304    .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; }
17305    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17306    .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)); }
17307    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17308    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17309    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17310    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17311    .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; }
17312    .nav-project-pill.visible { display:inline-flex; }
17313    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17314    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17315    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17316    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17317    @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; } }
17318    .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; }
17319    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17320    .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; }
17321    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17322    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17323    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17324    .theme-toggle .icon-sun { display:none; }
17325    body.dark-theme .theme-toggle .icon-sun { display:block; }
17326    body.dark-theme .theme-toggle .icon-moon { display:none; }
17327    .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;}
17328    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17329    .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);}
17330    .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;}
17331    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17332    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17333    .settings-modal-body{padding:14px 16px 16px;}
17334    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17335    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17336    .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;}
17337    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17338    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17339    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17340    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17341    .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;}
17342    .tz-select:focus{border-color:var(--oxide);}
17343    .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; }
17344    .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;}
17345    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17346    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17347    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17348    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17349    .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; }
17350    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17351    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17352    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17353    .wb-stats-header { padding: 10px 24px 0; }
17354    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17355    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17356    .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; }
17357    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17358    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17359    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17360    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17361    .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; }
17362    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17363    .ws-stat-analyzers { position: relative; }
17364    .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; }
17365    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17366    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17367    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17368    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17369    .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; }
17370    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17371    .ws-divider { display: none; }
17372    .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%; }
17373    .ws-path-link:hover { color:var(--oxide); }
17374    body.dark-theme .ws-path-link { color:var(--oxide); }
17375    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17376    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17377    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17378    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17379    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17380    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17381    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17382    .ws-mini-box-lg { flex:2 1 0; }
17383    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17384    .ws-mini-box-br { flex:1.5 1 0; }
17385    .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; }
17386    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17387    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17388    #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; }
17389    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17390    .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; }
17391    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17392    .git-source-banner strong { font-weight:800; color:var(--text); }
17393    .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; }
17394    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17395    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17396    .git-source-banner a:hover { text-decoration:underline; }
17397    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17398    .path-scope-sep { background:var(--line); margin:4px 14px; }
17399    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17400    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17401    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17402    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17403    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17404    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17405    .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; }
17406    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17407    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17408    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17409    .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; }
17410    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17411    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17412    [data-wb-tip] { cursor:help; }
17413    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17414    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17415    .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; }
17416    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17417    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17418    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17419    .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; }
17420    .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); }
17421    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17422    .side-info-card { padding: 18px; }
17423    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17424    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17425    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17426    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17427    .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); }
17428    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17429    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17430    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17431    .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; }
17432    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17433    .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; }
17434    .side-stack::-webkit-scrollbar { display: none; }
17435    .step-nav { padding: 20px 16px; }
17436    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17437    .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; }
17438    .step-button:hover { background: var(--surface-2); }
17439    .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); }
17440    .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; }
17441    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17442    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17443    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17444    .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); }
17445    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17446    .step-nav-sum-row:last-child { border-bottom:none; }
17447    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17448    .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; }
17449    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17450    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17451    .quick-scan-section { padding: 10px 4px 14px; }
17452    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17453    .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; }
17454    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17455    .quick-scan-btn:active { transform:translateY(0); }
17456    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17457    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17458    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17459    @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);} }
17460    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17461    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17462    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17463    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17464    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17465    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17466    .step-button.done .step-check { opacity:1; }
17467    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17468    .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; }
17469    .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; }
17470    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17471    .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; }
17472    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17473    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17474    .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; }
17475    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17476    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17477    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17478    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17479    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17480    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17481    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17482    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17483    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17484    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17485    .card-body { padding: 22px; }
17486    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17487    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17488    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17489    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17490    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17491    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17492    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17493    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17494    .field { min-width:0; }
17495    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17496    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; }
17497    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); }
17498    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17499    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); }
17500    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17501    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17502    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17503    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17504    .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; }
17505    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17506    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17507    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17508    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17509    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17510    .input-group.compact { grid-template-columns: 1fr auto auto; }
17511    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17512    .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)); }
17513    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17514    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17515    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17516    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17517    .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; }
17518    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17519    .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; }
17520    .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); }
17521    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17522    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17523    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17524    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17525    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17526    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17527    @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; } }
17528    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17529    button.secondary { background: var(--surface); }
17530    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17531    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17532    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17533    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17534    .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); }
17535    .section + .wizard-actions { border-top: none; padding-top: 0; }
17536    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17537    .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; }
17538    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17539    .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; }
17540    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17541    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17542    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17543    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17544    .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); }
17545    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17546    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17547    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17548    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17549    .field-help-grid.coupled-help { margin-top: 12px; }
17550    .field-help-grid.preset-grid { align-items: start; }
17551    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17552    .preset-inline-row .field { margin: 0; }
17553    .preset-inline-row .explainer-card { margin: 0; }
17554    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17555    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17556    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17557    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17558    .preset-kv-row > :last-child { flex:1; min-width:0; }
17559    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17560    .output-field-row .field { margin: 0; }
17561    .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; }
17562    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17563    .step3-subtitle { margin-bottom: 10px; max-width: none; }
17564    .counting-intro { margin-bottom: 8px; max-width: none; }
17565    .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; }
17566    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17567    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17568    .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; }
17569    .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; }
17570    .section-spacer-top { margin-top: 28px; }
17571    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17572    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17573    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17574    .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); }
17575    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17576    .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; }
17577    .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; }
17578    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17579    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17580    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17581    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17582    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17583    .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; }
17584    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17585    .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); }
17586    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; }
17587    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; }
17588    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17589    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17590    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17591    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17592    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17593    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17594    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17595    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17596    .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); }
17597    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17598    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17599    .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; }
17600    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17601    .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; }
17602    .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; }
17603    .always-tracked-tip-body { flex:1; min-width:0; }
17604    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17605    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17606    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17607    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17608    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17609    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17610    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17611    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17612    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17613    .advanced-rule-description strong { color: var(--text); }
17614    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17615    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17616    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17617    .review-link:hover { text-decoration: underline; }
17618    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17619    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17620    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17621    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17622    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17623    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17624    .review-card ul { padding-left: 18px; margin: 0; }
17625    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17626    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17627    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17628    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17629    .review-card { min-height: 0; }
17630    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17631    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17632    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17633    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17634    .lang-overflow-chip { position:relative; cursor:default; }
17635    .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; }
17636    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17637    .git-inline-row { align-items:start; }
17638    .mixed-line-card { display:flex; flex-direction:column; }
17639    .preset-inline-row .toggle-card { justify-content: center; }
17640        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17641    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17642    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17643    .explorer-title { font-size: 18px; font-weight: 850; }
17644    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17645    .explorer-subtitle.wide { max-width: none; }
17646    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17647    .better-spacing { align-items:flex-start; justify-content:flex-end; }
17648    .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; }
17649    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17650    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17651    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17652    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17653    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17654    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17655    .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; }
17656    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17657    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17658    .scope-stat-button.supported { background: var(--success-bg); }
17659    .scope-stat-button.skipped { background: var(--warn-bg); }
17660    .scope-stat-button.unsupported { background: var(--danger-bg); }
17661    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17662    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17663    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17664    [data-tooltip] { position: relative; }
17665    [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); }
17666    [data-tooltip]:hover::after { display: block; }
17667    .scope-stat-button[data-tooltip] { cursor: pointer; }
17668    .badge[data-tooltip] { cursor: help; }
17669    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17670    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17671    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17672    .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; }
17673    .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; }
17674    code { display:inline-block; margin-top:0; padding:2px 7px; }
17675    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17676    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17677    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17678    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17679    .language-pill.muted-pill { color: var(--muted); }
17680    button.language-pill { appearance:none; cursor:pointer; }
17681    .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); }
17682    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17683    .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; }
17684    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17685    .file-explorer-search-row { margin-left: auto; }
17686    .explorer-filter-select { min-width: 170px; width: 170px; }
17687    .explorer-search { min-width: 300px; width: 300px; }
17688    .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); }
17689    .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; }
17690    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17691    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17692    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17693    .file-explorer-tree { max-height: 640px; overflow:auto; }
17694    .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); }
17695    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17696    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17697    .tree-row.hidden-by-filter { display:none !important; }
17698    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17699    .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; }
17700    .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; }
17701    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17702    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17703    .tree-node { display:inline-flex; align-items:center; min-width:0; }
17704    .tree-node-dir { color: var(--text); font-weight: 800; }
17705    .tree-node-supported { color: var(--success-text); }
17706    .tree-node-skipped { color: var(--warn-text); }
17707    .tree-node-unsupported { color: var(--danger-text); }
17708    .tree-node-more { color: var(--muted-2); font-style: italic; }
17709    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17710    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17711    .tree-status-cell { display:flex; justify-content:flex-start; }
17712    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17713    .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; }
17714    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17715    .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; }
17716    @keyframes prevSpin { to { transform:rotate(360deg); } }
17717    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17718    .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; }
17719    .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; }
17720    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17721    .preview-gate-info svg { width:16px; height:16px; }
17722    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17723    @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); } }
17724    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17725    .preview-loading-text { flex:1; min-width:0; }
17726    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17727    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17728    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17729    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17730    .cov-scan-idle { display:none; }
17731    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17732    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17733    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17734    .cov-scan-title { font-weight:600; font-size:12.5px; }
17735    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17736    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17737    .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; }
17738    .cov-scan-use:hover { opacity:.75; }
17739    .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; }
17740    .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; }
17741    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17742    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17743    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17744    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17745    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17746    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17747    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17748    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17749    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17750    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17751    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17752    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17753    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17754    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17755    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17756    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17757    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17758    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17759    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17760    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17761    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17762    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17763    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17764    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17765    .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); }
17766    .loading.active { display:flex; }
17767    /* Lock page scroll while the analysis modal is open so the removed scrollbar
17768       gutter doesn't pull the centered card slightly left of true center. */
17769    body.modal-open { overflow: hidden; }
17770    .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; }
17771    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17772    .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; }
17773    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17774    .loading-card > * { position:relative; z-index:1; }
17775    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17776    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%); }
17777    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17778    .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; }
17779    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17780    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17781    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17782    .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; }
17783    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17784    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17785    .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; }
17786    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17787    .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; }
17788    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17789    .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; }
17790    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17791    .lc-step.done { color:var(--muted);opacity:0.55; }
17792    .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; }
17793    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17794    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17795    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17796    .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; }
17797    .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; }
17798    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17799    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17800    .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; }
17801    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17802    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17803    .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; }
17804    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17805    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17806    .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; }
17807    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17808    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17809    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17810    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17811    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17812    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17813    .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; }
17814    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17815    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17816    .hidden { display:none !important; }
17817    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17818    .site-footer a{color:var(--muted);}
17819    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17820    @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; } }
17821    .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;}
17822    @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));}}
17823    .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;}
17824    .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; }
17825    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17826    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17827    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17828    .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; }
17829    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17830    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17831    .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; }
17832    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17833    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17834    .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; }
17835    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17836    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17837    .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; }
17838    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17839    .info-icon-btn:hover { color:var(--text); }
17840    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); }
17841    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17842    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17843    .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;}
17844    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17845    .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;}
17846    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17847    #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);}
17848    #offline-file-banner.show{display:flex;}
17849    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17850    #offline-file-banner .ofb-text{flex:1;}
17851    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17852    #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;}
17853    #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;}
17854    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17855    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17856    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17857    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17858    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17859    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17860    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17861  </style>
17862</head>
17863<body id="page-top">
17864  <div id="offline-file-banner" role="alert">
17865    <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>
17866    <span class="ofb-text">
17867      Charts, images, and navigation require the oxide-sloc server.
17868      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17869      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17870      The metric tables below are fully readable without the server.
17871    </span>
17872    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17873  </div>
17874  <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>
17875  <div class="background-watermarks" aria-hidden="true">
17876    <img src="/images/logo/logo-text.png" alt="" />
17877    <img src="/images/logo/logo-text.png" alt="" />
17878    <img src="/images/logo/logo-text.png" alt="" />
17879    <img src="/images/logo/logo-text.png" alt="" />
17880    <img src="/images/logo/logo-text.png" alt="" />
17881    <img src="/images/logo/logo-text.png" alt="" />
17882    <img src="/images/logo/logo-text.png" alt="" />
17883    <img src="/images/logo/logo-text.png" alt="" />
17884    <img src="/images/logo/logo-text.png" alt="" />
17885    <img src="/images/logo/logo-text.png" alt="" />
17886    <img src="/images/logo/logo-text.png" alt="" />
17887    <img src="/images/logo/logo-text.png" alt="" />
17888    <img src="/images/logo/logo-text.png" alt="" />
17889    <img src="/images/logo/logo-text.png" alt="" />
17890  </div>
17891  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17892  <div class="top-nav">
17893    <div class="top-nav-inner">
17894      <a class="brand" href="/">
17895        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17896        <div class="brand-copy">
17897          <div class="brand-title">OxideSLOC</div>
17898          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17899        </div>
17900      </a>
17901      <div class="nav-project-slot">
17902        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17903          <span class="nav-project-label">Project</span>
17904          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17905        </div>
17906      </div>
17907      <div class="nav-status">
17908        <a class="nav-pill" href="/">Home</a>
17909        <div class="nav-dropdown">
17910          <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>
17911          <div class="nav-dropdown-menu">
17912            <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>
17913          </div>
17914        </div>
17915        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17916        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17917        <div class="nav-dropdown">
17918          <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>
17919          <div class="nav-dropdown-menu">
17920            <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>
17921          </div>
17922        </div>
17923        <div class="server-status-wrap" id="server-status-wrap">
17924          <div class="nav-pill server-online-pill" id="server-status-pill">
17925            <span class="status-dot" id="status-dot"></span>
17926            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
17927            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17928          </div>
17929          <div class="server-status-tip">
17930            {% if server_mode %}
17931            OxideSLOC is running in server mode — accessible on your LAN.
17932            {% else %}
17933            OxideSLOC is running locally — only accessible from this machine.
17934            {% endif %}
17935            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17936          </div>
17937        </div>
17938        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17939          <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>
17940        </button>
17941        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17942          <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>
17943          <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>
17944        </button>
17945      </div>
17946    </div>
17947  </div>
17948
17949  <div class="loading" id="loading">
17950    <div class="loading-card" id="loading-card">
17951      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
17952      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17953      <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>
17954      <div class="lc-steps" id="lc-steps">
17955        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
17956        <div class="lc-step-arrow">›</div>
17957        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
17958        <div class="lc-step-arrow">›</div>
17959        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
17960        <div class="lc-step-arrow">›</div>
17961        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
17962      </div>
17963      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
17964      <div class="lc-metrics" id="lc-metrics">
17965        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
17966        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
17967        <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>
17968        <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>
17969      </div>
17970      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
17971      <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>
17972      <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>
17973      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
17974      <div class="lc-actions hidden" id="lc-actions">
17975        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
17976        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
17977      </div>
17978      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
17979        <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>
17980        Cancel scan
17981      </button>
17982    </div>
17983  </div>
17984
17985  <div class="page">
17986    <div class="workbench-strip">
17987      <div class="workbench-box wb-stats">
17988        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
17989          <span class="wb-stats-title">Analysis session</span>
17990        </div>
17991        <div class="ws-left">
17992          <div class="ws-stat ws-stat-analyzers">
17993            <span class="ws-label">Analyzers</span>
17994            <span class="ws-value">
17995              <span class="ws-badge">60 languages</span>
17996            </span>
17997            <div class="ws-lang-tooltip">
17998              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
17999              <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>
18000              <div class="ws-lang-grid">
18001                <span class="ws-lang-item">Assembly</span>
18002                <span class="ws-lang-item">C</span>
18003                <span class="ws-lang-item">C++</span>
18004                <span class="ws-lang-item">C#</span>
18005                <span class="ws-lang-item">Clojure</span>
18006                <span class="ws-lang-item">CSS</span>
18007                <span class="ws-lang-item">Dart</span>
18008                <span class="ws-lang-item">Dockerfile</span>
18009                <span class="ws-lang-item">Elixir</span>
18010                <span class="ws-lang-item">Erlang</span>
18011                <span class="ws-lang-item">F#</span>
18012                <span class="ws-lang-item">Go</span>
18013                <span class="ws-lang-item">Groovy</span>
18014                <span class="ws-lang-item">Haskell</span>
18015                <span class="ws-lang-item">HTML</span>
18016                <span class="ws-lang-item">Java</span>
18017                <span class="ws-lang-item">JavaScript</span>
18018                <span class="ws-lang-item">Julia</span>
18019                <span class="ws-lang-item">Kotlin</span>
18020                <span class="ws-lang-item">Lua</span>
18021                <span class="ws-lang-item">Makefile</span>
18022                <span class="ws-lang-item">Nim</span>
18023                <span class="ws-lang-item">Obj-C</span>
18024                <span class="ws-lang-item">OCaml</span>
18025                <span class="ws-lang-item">Perl</span>
18026                <span class="ws-lang-item">PHP</span>
18027                <span class="ws-lang-item">PowerShell</span>
18028                <span class="ws-lang-item">Python</span>
18029                <span class="ws-lang-item">R</span>
18030                <span class="ws-lang-item">Ruby</span>
18031                <span class="ws-lang-item">Rust</span>
18032                <span class="ws-lang-item">Scala</span>
18033                <span class="ws-lang-item">SCSS</span>
18034                <span class="ws-lang-item">Shell</span>
18035                <span class="ws-lang-item">SQL</span>
18036                <span class="ws-lang-item">Svelte</span>
18037                <span class="ws-lang-item">Swift</span>
18038                <span class="ws-lang-item">TypeScript</span>
18039                <span class="ws-lang-item">Vue</span>
18040                <span class="ws-lang-item">XML</span>
18041                <span class="ws-lang-item">Zig</span>
18042                <span class="ws-lang-item">Solidity</span>
18043                <span class="ws-lang-item">Protobuf</span>
18044                <span class="ws-lang-item">HCL</span>
18045                <span class="ws-lang-item">GraphQL</span>
18046                <span class="ws-lang-item">Ada</span>
18047                <span class="ws-lang-item">VHDL</span>
18048                <span class="ws-lang-item">Verilog</span>
18049                <span class="ws-lang-item">Tcl</span>
18050                <span class="ws-lang-item">Pascal</span>
18051                <span class="ws-lang-item">Visual Basic</span>
18052                <span class="ws-lang-item">Lisp</span>
18053                <span class="ws-lang-item">Fortran</span>
18054                <span class="ws-lang-item">Nix</span>
18055                <span class="ws-lang-item">Crystal</span>
18056                <span class="ws-lang-item">D</span>
18057                <span class="ws-lang-item">GLSL</span>
18058                <span class="ws-lang-item">CMake</span>
18059                <span class="ws-lang-item">Elm</span>
18060                <span class="ws-lang-item">Awk</span>
18061              </div>
18062            </div>
18063          </div>
18064          <div class="ws-divider"></div>
18065          <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>
18066          <div class="ws-divider"></div>
18067          <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts \u2014 JSON, HTML, and PDF reports \u2014 are written after each completed scan.">
18068            <span class="ws-label">Output</span>
18069            <span class="ws-value">
18070              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18071                <span id="ws-output-root">project/sloc</span>
18072              </button>
18073            </span>
18074          </div>
18075        </div>
18076      </div>
18077      <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.">
18078        <div class="ws-history-label">Scan history</div>
18079        <div class="ws-history-inner">
18080          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18081            <div class="ws-mini-label">Scans</div>
18082            <div class="ws-mini-value" id="ws-scan-count">—</div>
18083          </div>
18084          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18085            <div class="ws-mini-label">Last Scan</div>
18086            <div class="ws-mini-value" id="ws-last-scan">—</div>
18087          </div>
18088          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18089            <div class="ws-mini-label">Branch</div>
18090            <div class="ws-mini-value" id="ws-branch">—</div>
18091          </div>
18092        </div>
18093      </div>
18094    </div>
18095
18096    <div class="layout">
18097      <aside class="side-stack">
18098        <section class="step-nav">
18099        <h3>Guided scan setup</h3>
18100        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18101          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18102          Top of page
18103        </a>
18104        <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>
18105        <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>
18106        <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>
18107        <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>
18108
18109        <div class="step-steps-divider"></div>
18110
18111        <div class="step-nav-info" id="step-nav-info">
18112          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18113          <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>
18114        </div>
18115
18116        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18117          <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>
18118          <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>
18119          <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>
18120        </div>
18121
18122        <div class="quick-scan-divider"></div>
18123        <div class="quick-scan-section">
18124          <div class="quick-scan-label">No customization needed?</div>
18125          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18126            <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>
18127            Quick Scan
18128          </button>
18129          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18130        </div>
18131
18132        <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>
18133        <div class="sidebar-scroll-divider"></div>
18134        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18135          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18136          Skip to bottom
18137        </a>
18138        </section>
18139
18140      </aside>
18141
18142      <section class="card">
18143        <div class="card-header">
18144          <div class="card-title-row">
18145            <div>
18146              <h1 class="card-title">Guided scan configuration</h1>
18147              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18148            </div>
18149            <div class="wizard-progress" aria-label="Scan setup progress">
18150              <div class="wizard-progress-top">
18151                <span class="wizard-progress-label">Setup progress</span>
18152                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18153              </div>
18154              <div class="wizard-progress-track">
18155                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18156              </div>
18157            </div>
18158          </div>
18159        </div>
18160        <div class="card-body">
18161          <form method="post" action="/analyze" id="analyze-form">
18162            <div class="wizard-step active" data-step="1">
18163              <div class="section">
18164                <div class="section-kicker">Step 1</div>
18165                <h2>Select project and preview scope</h2>
18166                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18167                <div class="field">
18168                  <label for="path">Project path</label>
18169                  {% if !git_repo.is_empty() %}
18170                  <div class="git-source-banner">
18171                    <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>
18172                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18173                    <a href="/git-browser">← Back to Git Browser</a>
18174                  </div>
18175                  {% endif %}
18176                  <div class="path-scope-grid">
18177                      {% if !git_repo.is_empty() %}
18178                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18179                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18180                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18181                      {% else %}
18182                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18183                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18184                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18185                      {% endif %}
18186                    <div class="path-scope-sep"></div>
18187                    <div class="scope-legend-row">
18188                      <span class="scope-legend-label">Scope legend:</span>
18189                      <span class="scope-legend-badges">
18190                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer \u2014 counted in SLOC totals.">supported</span>
18191                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18192                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set \u2014 listed but not counted.">unsupported</span>
18193                      </span>
18194                    </div>
18195                  </div>
18196                  {% if git_repo.is_empty() %}
18197                  {% if server_mode %}
18198                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18199                    ℹ️ Files are compressed and streamed — no fixed size limit.
18200                  </div>
18201                  {% endif %}
18202                  <div class="path-info-row">
18203                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18204                      <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>
18205                      <span id="project-size-text">Project size: —</span>
18206                    </button>
18207                  </div>
18208                  {% else %}
18209                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18210                  {% endif %}
18211                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18212                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18213                </div>
18214
18215                <div class="scope-preview-divider" aria-hidden="true"></div>
18216
18217                <div id="preview-panel">
18218                  <div class="preview-error">Loading preview...</div>
18219                </div>
18220              </div>
18221
18222              <div class="section" style="margin-top:14px;">
18223                <div class="preset-inline-row git-inline-row">
18224                  <div class="toggle-card" style="margin:0;">
18225                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18226                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18227                    <label class="checkbox">
18228                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18229                      <div>
18230                        <span>Detect and separate git submodules</span>
18231                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18232                      </div>
18233                    </label>
18234                  </div>
18235                  <div class="explainer-card prominent" style="margin:0;">
18236                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18237                    <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>
18238                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18239    path = libs/core
18240    url  = https://github.com/org/core.git
18241
18242[submodule "libs/ui"]
18243    path = libs/ui
18244    url  = https://github.com/org/ui.git</div>
18245                  </div>
18246                </div>
18247              </div>
18248
18249              <div class="section">
18250                <div class="field-grid">
18251                  <div class="field">
18252                    <div class="glob-label-row">
18253                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18254                      <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>
18255                    </div>
18256                    <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>
18257                    <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>
18258                  </div>
18259                  <div class="field">
18260                    <div class="glob-label-row">
18261                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18262                    </div>
18263                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
18264                    <div id="quick-exclude-chips" class="quick-excl-row">
18265                      <span class="quick-excl-label">Quick add:</span>
18266                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18267                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18268                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18269                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18270                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18271                      <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>
18272                    </div>
18273                    <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>
18274                  </div>
18275                </div>
18276                <div class="glob-guidance-grid">
18277                  <div class="glob-guidance-card">
18278                    <strong>How to read them</strong>
18279                    <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>
18280                  </div>
18281                  <div class="glob-guidance-card">
18282                    <strong>Common include examples</strong>
18283                    <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>
18284                  </div>
18285                  <div class="glob-guidance-card">
18286                    <strong>Common exclude examples</strong>
18287                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18288                  </div>
18289                </div>
18290              </div>
18291
18292              <div class="section" style="margin-top:14px;">
18293                <div class="preset-inline-row git-inline-row">
18294                  <div class="toggle-card" style="margin:0;">
18295                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18296                    <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>
18297                    <div class="field" style="margin:0;">
18298                      <div class="input-group compact">
18299                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18300                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18301                      </div>
18302                      <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>
18303                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18304                    </div>
18305                  </div>
18306                  <div class="explainer-card prominent" style="margin:0;">
18307                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18308                    <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>
18309                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18310lcov --capture --directory . --output-file coverage/lcov.info
18311
18312# C / C++ — llvm-cov (LCOV)
18313llvm-profdata merge -sparse default.profraw -o default.profdata
18314llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18315
18316# C# — coverlet (Cobertura XML)
18317dotnet test --collect:"XPlat Code Coverage"
18318
18319# Python — pytest-cov (Cobertura XML)
18320pytest --cov --cov-report=xml
18321
18322# Python — coverage.py native JSON
18323coverage run -m pytest && coverage json   # writes coverage.json
18324
18325# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18326./gradlew jacocoTestReport</div>
18327                  </div>
18328                </div>
18329              </div>
18330
18331              <div class="wizard-actions">
18332                <div class="left"></div>
18333                <div class="right">
18334                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18335                    <span class="preview-gate-spinner" aria-hidden="true"></span>
18336                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
18337                    <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">
18338                      <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>
18339                    </button>
18340                  </div>
18341                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18342                </div>
18343              </div>
18344            </div>
18345
18346            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18347              <div class="default-path-modal">
18348                <h3 id="default-path-title">
18349                  <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>
18350                  Proceed with the default sample test?
18351                </h3>
18352                <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18353                <p>You haven&#39;t selected your own project yet.</p>
18354                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18355                <div class="default-path-actions">
18356                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18357                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18358                </div>
18359              </div>
18360            </div>
18361
18362            <div class="wizard-step" data-step="2">
18363              <div class="section">
18364                <div class="section-kicker">Step 2</div>
18365                <h2>Choose counting behavior</h2>
18366                <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>
18367<div class="subsection-bar">Primary line classification</div>
18368                <div class="preset-kv-row">
18369                  <div class="toggle-card mixed-line-card" style="margin:0;">
18370                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18371                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18372                    <select id="mixed_line_policy" name="mixed_line_policy">
18373                      <option value="code_only">Code only</option>
18374                      <option value="code_and_comment">Code and comment</option>
18375                      <option value="comment_only">Comment only</option>
18376                      <option value="separate_mixed_category">Separate mixed category</option>
18377                    </select>
18378                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18379                  </div>
18380                  <div class="explainer-card prominent" style="margin:0;">
18381                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18382                    <div class="explainer-body" id="mixed-policy-description"></div>
18383                    <div class="code-sample" id="mixed-policy-example"></div>
18384                  </div>
18385                </div>
18386              </div>
18387
18388              <div class="subsection-bar">Additional scan rules</div>
18389              <div class="scan-rules-grid">
18390                <div class="preset-inline-row">
18391                  <div class="toggle-card" style="margin:0;">
18392                    <div class="field-help-title">Generated files</div>
18393                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18394                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18395                  </div>
18396                  <div class="explainer-card prominent" style="margin:0;">
18397                    <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>
18398                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18399# Files matching codegen patterns are excluded:
18400#   *.generated.cs  *.pb.go  *.g.dart</div>
18401                  </div>
18402                </div>
18403                <div class="preset-inline-row">
18404                  <div class="toggle-card" style="margin:0;">
18405                    <div class="field-help-title">Minified files</div>
18406                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18407                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18408                  </div>
18409                  <div class="explainer-card prominent" style="margin:0;">
18410                    <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>
18411                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18412# Heuristic: very long lines + low whitespace ratio
18413#   jquery.min.js  bundle.min.css  → skipped</div>
18414                  </div>
18415                </div>
18416                <div class="preset-inline-row">
18417                  <div class="toggle-card" style="margin:0;">
18418                    <div class="field-help-title">Vendor directories</div>
18419                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18420                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18421                  </div>
18422                  <div class="explainer-card prominent" style="margin:0;">
18423                    <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>
18424                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18425# Directories named vendor/ node_modules/ third_party/
18426#   → entire subtree is excluded from totals</div>
18427                  </div>
18428                </div>
18429                <div class="preset-inline-row">
18430                  <div class="toggle-card" style="margin:0;">
18431                    <div class="field-help-title">Lockfiles and manifests</div>
18432                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18433                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18434                  </div>
18435                  <div class="explainer-card prominent" style="margin:0;">
18436                    <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>
18437                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
18438# Files like package-lock.json  Cargo.lock  yarn.lock
18439#   → skipped unless this is enabled</div>
18440                  </div>
18441                </div>
18442                <div class="preset-inline-row">
18443                  <div class="toggle-card" style="margin:0;">
18444                    <div class="field-help-title">Binary handling</div>
18445                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18446                    <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>
18447                  </div>
18448                  <div class="explainer-card prominent" style="margin:0;">
18449                    <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>
18450                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
18451# Detected via long lines + low whitespace heuristic
18452#   .png  .exe  .so  → skipped silently</div>
18453                  </div>
18454                </div>
18455                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18456                  <div class="toggle-card" style="margin:0;">
18457                    <div class="field-help-title">Python docstrings</div>
18458                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18459                    <label class="checkbox">
18460                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18461                      <span>Count as comment-style lines</span>
18462                    </label>
18463                  </div>
18464                  <div class="explainer-card prominent" style="margin:0;">
18465                    <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>
18466                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18467                  </div>
18468                </div>
18469              </div>
18470              <div class="subsection-bar">IEEE 1045-1992 counting</div>
18471              <div class="scan-rules-grid">
18472                <div class="preset-inline-row">
18473                  <div class="toggle-card" style="margin:0;">
18474                    <div class="field-help-title">Continuation lines</div>
18475                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18476                    <select name="continuation_line_policy" id="continuation_line_policy">
18477                      <option value="each_physical_line" selected>Each physical line (default)</option>
18478                      <option value="collapse_to_logical">Collapse to logical line</option>
18479                    </select>
18480                  </div>
18481                  <div class="explainer-card prominent" style="margin:0;">
18482                    <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>
18483                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18484    ((a) &gt; (b) ? (a) : (b))
18485# each_physical_line → 2 SLOC
18486# collapse_to_logical → 1 SLOC</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">Block-comment blanks</div>
18492                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18493                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18494                      <option value="count_as_comment" selected>Count as comment (default)</option>
18495                      <option value="count_as_blank">Count as blank</option>
18496                    </select>
18497                  </div>
18498                  <div class="explainer-card prominent" style="margin:0;">
18499                    <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>
18500                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18501 * Summary line
18502 *              ← blank inside block comment
18503 * Detail line
18504 */
18505# count_as_comment → blank counts toward comments
18506# count_as_blank   → blank counts toward blanks</div>
18507                  </div>
18508                </div>
18509                <div class="preset-inline-row">
18510                  <div class="toggle-card" style="margin:0;">
18511                    <div class="field-help-title">Compiler directives</div>
18512                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18513                    <select name="count_compiler_directives" id="count_compiler_directives">
18514                      <option value="enabled" selected>Include in code SLOC (default)</option>
18515                      <option value="disabled">Exclude from code SLOC</option>
18516                    </select>
18517                  </div>
18518                  <div class="explainer-card prominent" style="margin:0;">
18519                    <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>
18520                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
18521#define BUF 256     ← compiler directive
18522int main() { … }   ← code
18523# enabled  → 3 code SLOC
18524# disabled → 1 code SLOC + 2 directive lines</div>
18525                  </div>
18526                </div>
18527              </div>
18528
18529              <div class="subsection-bar">Code Style Analysis</div>
18530              <div class="scan-rules-grid">
18531                <div class="preset-inline-row">
18532                  <div class="toggle-card" style="margin:0;">
18533                    <div class="field-help-title">Style analysis</div>
18534                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18535                    <select name="style_analysis_enabled" id="style_analysis_enabled">
18536                      <option value="enabled" selected>Enabled (default)</option>
18537                      <option value="disabled">Disabled — skip style scoring</option>
18538                    </select>
18539                  </div>
18540                  <div class="explainer-card prominent" style="margin:0;">
18541                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> \u2014 every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> \u2014 style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
18542                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
18543# style_analysis_enabled = false  (skip, faster scan)
18544# Disabling removes the Code Style section from the report.</div>
18545                  </div>
18546                </div>
18547                <div class="preset-inline-row">
18548                  <div class="toggle-card" style="margin:0;">
18549                    <div class="field-help-title">Column-width threshold</div>
18550                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18551                    <select name="style_col_threshold" id="style_col_threshold">
18552                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18553                      <option value="100">100 columns (Uber Go, Google Java)</option>
18554                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
18555                    </select>
18556                  </div>
18557                  <div class="explainer-card prominent" style="margin:0;">
18558                    <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>
18559                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
18560# style_col_threshold = 100 (Uber Go, Google Java)
18561# style_col_threshold = 120 (Uber Go max, Kotlin)
18562# Files where &lt;= 5% of lines exceed the limit
18563# are counted as "N-col compliant" in the report.</div>
18564                  </div>
18565                </div>
18566                <div class="preset-inline-row">
18567                  <div class="toggle-card" style="margin:0;">
18568                    <div class="field-help-title">Score alert threshold</div>
18569                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18570                    <select name="style_score_threshold" id="style_score_threshold">
18571                      <option value="0" selected>Off — no threshold (default)</option>
18572                      <option value="40">40% — flag poorly styled files</option>
18573                      <option value="50">50% — flag below-average files</option>
18574                      <option value="60">60% — flag below-good files</option>
18575                      <option value="70">70% — flag below-strong files</option>
18576                    </select>
18577                  </div>
18578                  <div class="explainer-card prominent" style="margin:0;">
18579                    <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>
18580                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
18581# style_score_threshold = 50  (flag files &lt; 50%)
18582# Low-scoring files get a red left-border in the
18583# per-file style breakdown table.</div>
18584                  </div>
18585                </div>
18586              </div>
18587
18588              <div class="always-tracked-tip">
18589                <div class="always-tracked-tip-icon">ℹ</div>
18590                <div class="always-tracked-tip-body">
18591                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
18592                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
18593                  <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>
18594                </div>
18595              </div>
18596
18597              <div class="subsection-bar">Advanced Metrics</div>
18598              <div class="scan-rules-grid">
18599                <div class="preset-inline-row">
18600                  <div class="toggle-card" style="margin:0;">
18601                    <div class="field-help-title">COCOMO mode</div>
18602                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18603                    <select name="cocomo_mode" id="cocomo_mode">
18604                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18605                      <option value="semi_detached">Semi-detached — mixed constraints</option>
18606                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
18607                    </select>
18608                  </div>
18609                  <div class="explainer-card prominent" style="margin:0;">
18610                    <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>
18611                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
18612# Semi-detached: Effort = 3.0 × KSLOC^1.12
18613# Embedded:     Effort = 3.6 × KSLOC^1.20
18614# All modes: Schedule = 2.5 × Effort^d</div>
18615                  </div>
18616                </div>
18617                <div class="preset-inline-row">
18618                  <div class="toggle-card" style="margin:0;">
18619                    <div class="field-help-title">Complexity alert</div>
18620                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18621                    <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;" />
18622                  </div>
18623                  <div class="explainer-card prominent" style="margin:0;">
18624                    <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>
18625                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18626# 50  = flag any file with &gt; 50 branch points
18627# 100 = flag any file with &gt; 100 branch points
18628# Files above the threshold are highlighted
18629# in the result page metric strip.</div>
18630                  </div>
18631                </div>
18632                <div class="preset-inline-row">
18633                  <div class="toggle-card" style="margin:0;">
18634                    <div class="field-help-title">Git hotspots</div>
18635                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18636                    <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;" />
18637                  </div>
18638                  <div class="explainer-card prominent" style="margin:0;">
18639                    <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>
18640                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
18641# 30  = last month of activity
18642# 365 = last year
18643# 0   = disable the hotspots table
18644# Adds Commits + Last-changed columns to CSV.</div>
18645                  </div>
18646                </div>
18647                <div class="preset-inline-row">
18648                  <div class="toggle-card" style="margin:0;">
18649                    <div class="field-help-title">Duplicate handling</div>
18650                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18651                    <select name="exclude_duplicates" id="exclude_duplicates">
18652                      <option value="disabled" selected>Detect and report only (default)</option>
18653                      <option value="enabled">Detect and exclude from SLOC totals</option>
18654                    </select>
18655                  </div>
18656                  <div class="explainer-card prominent" style="margin:0;">
18657                    <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>
18658                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18659# detect only   → all 3 counted in SLOC
18660# exclude dupes → 1 counted, 2 excluded
18661# Duplicate groups chip always shows the count.</div>
18662                  </div>
18663                </div>
18664                <div class="always-tracked-tip" style="margin:8px 0 0;">
18665                  <div class="always-tracked-tip-icon">ℹ</div>
18666                  <div class="always-tracked-tip-body">
18667                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
18668                    <div class="always-tracked-metrics-row">
18669                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18670                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
18671                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
18672                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
18673                    </div>
18674                    <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>
18675                  </div>
18676                </div>
18677              </div>
18678
18679              <div class="wizard-actions">
18680                <div class="left">
18681                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18682                </div>
18683                <div class="right">
18684                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18685                </div>
18686              </div>
18687            </div>
18688
18689            <div class="wizard-step" data-step="3">
18690              <div class="section">
18691                <div class="section-kicker">Step 3</div>
18692                <h2>Output and report identity</h2>
18693                <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>
18694                <div class="preset-kv-row">
18695                  <div class="toggle-card" style="margin:0;">
18696                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18697                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18698                    <select id="scan_preset">
18699                      <option value="balanced">Balanced local scan</option>
18700                      <option value="code_focused">Code focused</option>
18701                      <option value="comment_audit">Comment audit</option>
18702                      <option value="deep_review">Deep review</option>
18703                    </select>
18704                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18705                  </div>
18706                  <div class="explainer-card">
18707                    <div class="field-help-title">Selected scan preset</div>
18708                    <div class="explainer-body" id="scan-preset-description"></div>
18709                    <div class="preset-summary-row" id="scan-preset-summary"></div>
18710                    <div class="code-sample" id="scan-preset-example"></div>
18711                    <div class="preset-note" id="scan-preset-note"></div>
18712                  </div>
18713                </div>
18714                <hr class="step3-separator" />
18715                <div class="preset-kv-row">
18716                  <div class="toggle-card" style="margin:0;">
18717                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18718                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18719                    <select id="artifact_preset">
18720                      <option value="review">Review bundle</option>
18721                      <option value="full">Full bundle</option>
18722                      <option value="html_only">HTML only</option>
18723                      <option value="machine">Machine bundle</option>
18724                    </select>
18725                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18726                  </div>
18727                  <div class="explainer-card">
18728                    <div class="field-help-title">Selected artifact preset</div>
18729                    <div class="explainer-body" id="artifact-preset-description"></div>
18730                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
18731                    <div class="code-sample" id="artifact-preset-example"></div>
18732                  </div>
18733                </div>
18734              </div>
18735
18736              <div class="section section-spacer-top">
18737                <div class="output-field-row">
18738                  <div class="field">
18739                    <label for="output_dir">Output directory</label>
18740                    {% if server_mode %}
18741                    <div class="input-group compact">
18742                      <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);" />
18743                    </div>
18744                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18745                    {% else %}
18746                    <div class="input-group compact">
18747                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18748                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18749                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
18750                    </div>
18751                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18752                    {% endif %}
18753                  </div>
18754                  <div class="output-field-aside">
18755                    <strong>Where reports land</strong>
18756                    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.
18757                  </div>
18758                </div>
18759              </div>
18760
18761              <div class="section section-spacer-top">
18762                <div class="output-field-row">
18763                  <div class="field">
18764                    <label for="report_title">Report title</label>
18765                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18766                    <div class="hint">Appears in HTML and PDF output headers.</div>
18767                  </div>
18768                  <div class="output-field-aside">
18769                    <strong>Shown in exported artifacts</strong>
18770                    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.
18771                  </div>
18772                </div>
18773              </div>
18774
18775              <div class="section section-spacer-top">
18776                <div class="output-field-row">
18777                  <div class="field">
18778                    <label for="report_header_footer">Report header / footer</label>
18779                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18780                    <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>
18781                  </div>
18782                  <div class="output-field-aside">
18783                    <strong>Page-level identification</strong>
18784                    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.
18785                  </div>
18786                </div>
18787              </div>
18788
18789              <div class="wizard-actions">
18790                <div class="left">
18791                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18792                </div>
18793                <div class="right">
18794                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18795                </div>
18796              </div>
18797            </div>
18798
18799            <div class="wizard-step" data-step="4">
18800              <div class="section">
18801                <div class="section-kicker">Step 4</div>
18802                <h2>Review selections and run</h2>
18803                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18804                <div class="review-grid">
18805                  <div class="review-card highlight">
18806                    <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>
18807                    <ul id="review-scan-summary"></ul>
18808                  </div>
18809                  <div class="review-card highlight">
18810                    <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>
18811                    <ul id="review-count-summary"></ul>
18812                  </div>
18813                  <div class="review-card">
18814                    <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>
18815                    <ul id="review-artifact-summary"></ul>
18816                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18817                  </div>
18818                  <div class="review-card">
18819                    <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>
18820                    <ul id="review-preview-summary"></ul>
18821                  </div>
18822                </div>
18823              </div>
18824
18825              <div class="wizard-actions">
18826                <div class="left">
18827                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18828                </div>
18829                <div class="right">
18830                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
18831                </div>
18832              </div>
18833            </div>
18834            {% if server_mode %}
18835            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18836            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18837            {% endif %}
18838          </form>
18839        </div>
18840      </section>
18841    </div>
18842  </div>
18843
18844  <script nonce="{{ csp_nonce }}">
18845    (function () {
18846      function startScanPhase() {
18847        var phaseEl = document.getElementById("scan-phase");
18848        if (!phaseEl) return;
18849        var phases = [
18850          "Discovering files...",
18851          "Decoding file encodings...",
18852          "Detecting languages...",
18853          "Analyzing source lines...",
18854          "Applying counting policies...",
18855          "Aggregating results...",
18856          "Rendering report..."
18857        ];
18858        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18859        var i = 0;
18860        function next() {
18861          phaseEl.style.opacity = "0";
18862          setTimeout(function () {
18863            phaseEl.textContent = phases[i];
18864            phaseEl.style.opacity = "0.85";
18865            var delay = durations[i] || 1800;
18866            i++;
18867            if (i < phases.length) { setTimeout(next, delay); }
18868          }, 200);
18869        }
18870        next();
18871      }
18872
18873      var form = document.getElementById("analyze-form");
18874      var loading = document.getElementById("loading");
18875      var submitButton = document.getElementById("submit-button");
18876      var pathInput = document.getElementById("path");
18877      var GIT_MODE = !!(pathInput && pathInput.readOnly);
18878      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18879      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18880      var outputDirInput = document.getElementById("output_dir");
18881      var reportTitleInput = document.getElementById("report_title");
18882      var previewPanel = document.getElementById("preview-panel");
18883      var refreshButton = document.getElementById("refresh-preview");
18884      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18885      var useSamplePath = document.getElementById("use-sample-path");
18886      var useDefaultOutput = document.getElementById("use-default-output");
18887      var browsePath = document.getElementById("browse-path");
18888      var browseOutputDir = document.getElementById("browse-output-dir");
18889      var browseCoverage = document.getElementById("browse-coverage");
18890      var coverageInput = document.getElementById("coverage_file");
18891      var covScanStatus = document.getElementById("cov-scan-status");
18892      var coverageSuggestTimer = null;
18893      var covAutoFilled = false;
18894      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18895
18896      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18897      (function() {
18898        var ids = ["path", "output_dir"];
18899        ids.forEach(function(id) {
18900          var el = document.getElementById(id);
18901          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18902        });
18903      }());
18904      function fmtBytes(b) {
18905        b = Number(b) || 0;
18906        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18907        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18908        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
18909        return b + ' B';
18910      }
18911      var themeToggle = document.getElementById("theme-toggle");
18912
18913      function showBannerToast(msg, isError, opts) {
18914        opts = opts || {};
18915        var t = document.createElement('div');
18916        t.className = isError ? 'toast-error' : 'toast-success';
18917        var topPos = opts.top ? '80px' : null;
18918        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
18919          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
18920          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
18921          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
18922        if (opts.icon) {
18923          var inner = document.createElement('span');
18924          inner.innerHTML = opts.icon + ' ';
18925          t.appendChild(inner);
18926        }
18927        t.appendChild(document.createTextNode(msg));
18928        document.body.appendChild(t);
18929        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
18930      }
18931      var mixedLinePolicy = document.getElementById("mixed_line_policy");
18932      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
18933      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
18934      var scanPreset = document.getElementById("scan_preset");
18935      var artifactPreset = document.getElementById("artifact_preset");
18936      var includeGlobsInput = document.getElementById("include_globs");
18937      var excludeGlobsInput = document.getElementById("exclude_globs");
18938
18939      // Include globs scope badge — updates reactively as the user types.
18940      (function() {
18941        var badge = document.getElementById("include-scope-badge");
18942        if (!badge || !includeGlobsInput) return;
18943        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> ';
18944        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> ';
18945        function update() {
18946          var val = includeGlobsInput.value.trim();
18947          if (!val) {
18948            badge.className = "include-scope-badge scope-all";
18949            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
18950          } else {
18951            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
18952            badge.className = "include-scope-badge scope-narrow";
18953            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
18954          }
18955        }
18956        includeGlobsInput.addEventListener("input", update);
18957        update();
18958      }());
18959
18960      // Quick-exclude chips — append pattern to exclude_globs textarea.
18961      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
18962        chip.addEventListener("click", function() {
18963          var pattern = chip.getAttribute("data-pattern") || "";
18964          if (!pattern || !excludeGlobsInput) return;
18965          var current = excludeGlobsInput.value.trim();
18966          // For the "skip all" chip, replace any existing dep patterns cleanly.
18967          var patterns = pattern.split("\n");
18968          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
18969          var added = false;
18970          patterns.forEach(function(p) {
18971            p = p.trim();
18972            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
18973          });
18974          if (added) {
18975            excludeGlobsInput.value = lines.join("\n");
18976            excludeGlobsInput.dispatchEvent(new Event("input"));
18977          }
18978          chip.classList.add("active");
18979        });
18980      });
18981
18982      var liveReportTitle = document.getElementById("live-report-title");
18983      var navProjectPill = document.getElementById("nav-project-pill");
18984      var navProjectTitle = document.getElementById("nav-project-title");
18985      var reportTitlePreview = null;
18986      var wizardProgressFill = document.getElementById("wizard-progress-fill");
18987      var wizardProgressValue = document.getElementById("wizard-progress-value");
18988      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
18989      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
18990      var reportTitleTouched = false;
18991      var currentStep = 1;
18992      var previewTimer = null;
18993      var _previewGen = 0;
18994      // True while the scope preview (local) / project upload (server mode) is in
18995      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
18996      // user can't advance past a project whose scope/upload isn't ready yet.
18997      var previewLoading = false;
18998      function setPreviewLoading(loading) {
18999        previewLoading = !!loading;
19000        var nextBtn = document.getElementById("step1-next");
19001        var gate = document.getElementById("preview-gate-status");
19002        if (nextBtn) {
19003          nextBtn.classList.toggle("is-blocked", previewLoading);
19004          nextBtn.setAttribute("aria-disabled", previewLoading ? "true" : "false");
19005        }
19006        if (gate) {
19007          var txt = gate.querySelector(".preview-gate-text");
19008          if (txt) txt.textContent = SERVER_MODE
19009            ? "Uploading & scanning project…"
19010            : "Scanning project scope…";
19011          gate.style.display = previewLoading ? "flex" : "none";
19012        }
19013      }
19014      // Info button on the gate: scroll up to the live scope preview so the user
19015      // can see exactly what is being scanned (elapsed time + rotating status).
19016      var previewGateInfo = document.getElementById("preview-gate-info");
19017      if (previewGateInfo) {
19018        previewGateInfo.addEventListener("click", function () {
19019          var target = document.getElementById("preview-panel");
19020          if (!target) return;
19021          target.scrollIntoView({ behavior: "smooth", block: "center" });
19022          target.classList.add("preview-panel-flash");
19023          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19024        });
19025      }
19026      var quickScanBtn = document.getElementById("quick-scan-btn");
19027
19028      function dismissAnalysisModal() {
19029        if (loading) loading.classList.remove("active");
19030        document.body.classList.remove("modal-open");
19031        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19032          var el = document.getElementById(id);
19033          if (el) el.classList.add("hidden");
19034        });
19035        var cancelBtn = document.getElementById("lc-cancel-btn");
19036        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19037        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19038        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19039        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19040        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");}
19041        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19042        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19043        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19044        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19045        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19046        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19047      }
19048
19049      var lcDismissBtn = document.getElementById("lc-dismiss");
19050      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19051
19052      // When the browser restores this page from bfcache (Back button after navigating to results),
19053      // the loading overlay would still be showing its active state. Dismiss it immediately.
19054      window.addEventListener("pageshow", function(e) {
19055        if (e.persisted) { dismissAnalysisModal(); }
19056      });
19057
19058      function startAsyncAnalysis(formData) {
19059        var gitRepo = (formData.get("git_repo") || "").toString();
19060        var gitRef  = (formData.get("git_ref")  || "").toString();
19061        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19062        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19063
19064        var pathEl = document.getElementById("lc-path-text");
19065        if (pathEl) pathEl.textContent = displayPath;
19066
19067        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19068          var el = document.getElementById(id);
19069          if (el) el.classList.add("hidden");
19070        });
19071        var cancelBtn = document.getElementById("lc-cancel-btn");
19072        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19073        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19074        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19075        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19076        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19077        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19078        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19079        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");}
19080        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19081
19082        if (loading) loading.classList.add("active");
19083        document.body.classList.add("modal-open");
19084
19085        var startTime = Date.now();
19086        var elapsedTimer = setInterval(function() {
19087          var s = Math.floor((Date.now() - startTime) / 1000);
19088          var el = document.getElementById("lc-elapsed");
19089          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19090        }, 1000);
19091
19092        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19093
19094        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();}
19095
19096        var PHASE_DESC = {
19097          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19098          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19099          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19100          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19101          'Done': 'Analysis complete \u2014 loading your results\u2026',
19102          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19103        };
19104        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19105        function lcSetPhase(txt) {
19106          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19107          var desc = document.getElementById("lc-stage-desc");
19108          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19109          var step = PHASE_STEP[txt] || 1;
19110          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");}
19111        }
19112
19113        function lcShowCancelled() {
19114          clearInterval(elapsedTimer);
19115          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19116          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19117          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19118          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19119          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19120          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19121          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19122          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19123          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19124          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19125        }
19126
19127        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19128        if (lcCancelBtn) {
19129          lcCancelBtn.onclick = function() {
19130            if (!activeWaitId) { dismissAnalysisModal(); return; }
19131            lcCancelBtn.disabled = true;
19132            lcCancelBtn.textContent = "Cancelling\u2026";
19133            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19134              .then(function() { lcShowCancelled(); })
19135              .catch(function() { lcShowCancelled(); });
19136          };
19137        }
19138
19139        function lcShowError(msg) {
19140          clearInterval(elapsedTimer);
19141          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19142          lcSetPhase("Failed");
19143          var msgEl = document.getElementById("lc-err-msg");
19144          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19145          var errEl = document.getElementById("lc-err");
19146          var actEl = document.getElementById("lc-actions");
19147          if (errEl) errEl.classList.remove("hidden");
19148          if (actEl) actEl.classList.remove("hidden");
19149          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19150          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19151        }
19152
19153        function lcPoll(waitId) {
19154          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19155            .then(function(r) {
19156              if (!r.ok) throw new Error("HTTP " + r.status);
19157              return r.json();
19158            })
19159            .then(function(data) {
19160              pollRetries = 0;
19161              if (data.state === "complete") {
19162                clearInterval(elapsedTimer);
19163                lcSetPhase("Done");
19164                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19165              } else if (data.state === "failed") {
19166                lcShowError(data.message);
19167              } else if (data.state === "cancelled") {
19168                lcShowCancelled();
19169              } else {
19170                var s = Math.floor((Date.now() - startTime) / 1000);
19171                if (s > 90 && !warnShown) {
19172                  warnShown = true;
19173                  var w = document.getElementById("lc-warn");
19174                  if (w) w.classList.remove("hidden");
19175                }
19176                lcSetPhase(data.phase || "Running");
19177                var fd = data.files_done || 0, ft = data.files_total || 0;
19178                if (ft > 0) {
19179                  var card = document.getElementById("lc-files-card");
19180                  if (card) card.classList.remove("hidden");
19181                  var el = document.getElementById("lc-files");
19182                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19183                  var now = Date.now();
19184                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19185                  if (fdelta > 0 && tdelta > 0.4) {
19186                    var fps = Math.round(fdelta / tdelta);
19187                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19188                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19189                  }
19190                  lastFd = fd; lastFdTime = now;
19191                }
19192                setTimeout(function() { lcPoll(waitId); }, 1500);
19193              }
19194            })
19195            .catch(function() {
19196              pollRetries++;
19197              if (pollRetries >= 5) {
19198                lcShowError("Lost connection to server. Reload to check status.");
19199              } else {
19200                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19201              }
19202            });
19203        }
19204
19205        var params = new URLSearchParams(formData);
19206        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19207          .then(function(r) {
19208            var waitId = r.headers.get("x-wait-id");
19209            if (!waitId) { window.location.href = "/scan"; return; }
19210            activeWaitId = waitId;
19211            setTimeout(function() { lcPoll(waitId); }, 1500);
19212          })
19213          .catch(function(err) {
19214            lcShowError("Could not reach server: " + (err.message || err));
19215          });
19216      }
19217
19218      if (quickScanBtn) {
19219        quickScanBtn.addEventListener("click", function () {
19220          var pathVal = pathInput ? pathInput.value.trim() : "";
19221          if (!pathVal) {
19222            alert("Please enter or browse to a project path first.");
19223            return;
19224          }
19225          quickScanBtn.disabled = true;
19226          quickScanBtn.textContent = "Scanning...";
19227          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19228          startAsyncAnalysis(new FormData(form));
19229        });
19230      }
19231
19232      var mixedPolicyInfo = {
19233        code_only: {
19234          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.",
19235          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'
19236        },
19237        code_and_comment: {
19238          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.",
19239          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'
19240        },
19241        comment_only: {
19242          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.",
19243          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'
19244        },
19245        separate_mixed_category: {
19246          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.",
19247          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'
19248        }
19249      };
19250
19251      var scanPresetInfo = {
19252        balanced: {
19253          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.",
19254          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19255          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19256          note: "Best when you want a stable local overview before making deeper adjustments.",
19257          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19258        },
19259        code_focused: {
19260          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19261          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19262          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19263          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19264          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19265        },
19266        comment_audit: {
19267          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19268          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19269          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19270          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19271          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19272        },
19273        deep_review: {
19274          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19275          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19276          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19277          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19278          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19279        }
19280      };
19281
19282      var artifactPresetInfo = {
19283        review: {
19284          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19285          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19286          example: "Ideal for a quick local review before sharing results."
19287        },
19288        full: {
19289          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19290          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19291          example: "Use when producing a deliverable or storing a snapshot for future comparison."
19292        },
19293        html_only: {
19294          description: "Standalone HTML report only. No PDF generation, no data files.",
19295          chips: ["HTML only"],
19296          example: "Fastest option when you only need to open the report in a browser."
19297        },
19298        machine: {
19299          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19300          chips: ["JSON", "CSV", "no HTML", "no PDF"],
19301          example: "Use in CI to capture metrics without generating visual reports."
19302        }
19303      };
19304
19305      function applyArtifactPreset() {
19306        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19307        if (!info) return;
19308        var descEl = document.getElementById("artifact-preset-description");
19309        var exampleEl = document.getElementById("artifact-preset-example");
19310        if (descEl) descEl.textContent = info.description;
19311        if (exampleEl) exampleEl.textContent = info.example;
19312        renderPresetChips("artifact-preset-summary", info.chips);
19313      }
19314
19315      function applyTheme(theme) {
19316        if (theme === "dark") document.body.classList.add("dark-theme");
19317        else document.body.classList.remove("dark-theme");
19318      }
19319
19320      function loadSavedTheme() {
19321        var saved = null;
19322        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19323        applyTheme(saved === "dark" ? "dark" : "light");
19324      }
19325
19326      function updateScrollProgress() {
19327        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19328        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19329        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19330        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19331        var step = Math.min(Math.max(currentStep, 1), 4);
19332        var base = stepBase[step];
19333        var end  = stepEnd[step];
19334
19335        var scrollFrac = 0;
19336        var activePanel = document.querySelector(".wizard-step.active");
19337        if (activePanel) {
19338          var scrollTop = window.scrollY || window.pageYOffset || 0;
19339          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19340          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19341          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19342          var scrolled = scrollTop + viewH - panelTop;
19343          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19344        }
19345
19346        var percent = Math.round(base + (end - base) * scrollFrac);
19347        percent = Math.min(end, Math.max(base, percent));
19348        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19349        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19350      }
19351
19352      function updateWizardProgress() {
19353        updateScrollProgress();
19354      }
19355
19356      var stepDescriptions = [
19357        "Choose a project folder, apply scope filters, and preview which files will be counted.",
19358        "Configure how mixed code-plus-comment lines and docstrings are classified.",
19359        "Pick your output formats, scan preset, and where reports are saved.",
19360        "Review all settings and launch the analysis."
19361      ];
19362
19363      function updateStepNav(step) {
19364        var infoLabel = document.getElementById("step-nav-info-label");
19365        var infoDesc  = document.getElementById("step-nav-info-desc");
19366        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19367        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
19368      }
19369
19370      function updateSidebarSummary() {
19371        var sumPath    = document.getElementById("sum-path");
19372        var sumPreset  = document.getElementById("sum-preset");
19373        var sumOutput  = document.getElementById("sum-output");
19374        var sidebarSummary = document.getElementById("sidebar-summary");
19375        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19376        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
19377        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19378        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
19379        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19380        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19381        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19382      }
19383
19384      function setStep(step, pushHistory) {
19385        currentStep = step;
19386        stepPanels.forEach(function (panel) {
19387          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19388        });
19389        stepButtons.forEach(function (button) {
19390          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19391        });
19392        var layoutEl = document.querySelector(".layout");
19393        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19394        updateWizardProgress();
19395        updateStepNav(step);
19396        stepButtons.forEach(function(btn) {
19397          var t = Number(btn.getAttribute("data-step-target"));
19398          btn.classList.toggle("done", t < step);
19399        });
19400        updateSidebarSummary();
19401
19402        if (pushHistory !== false) {
19403          try {
19404            history.pushState({ wizardStep: step }, "", "#step" + step);
19405          } catch (e) {}
19406        }
19407
19408        window.scrollTo({ top: 0, behavior: "instant" });
19409      }
19410
19411      window.addEventListener("popstate", function (e) {
19412        if (e.state && e.state.wizardStep) {
19413          setStep(e.state.wizardStep, false);
19414        } else {
19415          var hashMatch = location.hash.match(/^#step([1-4])$/);
19416          if (hashMatch) setStep(Number(hashMatch[1]), false);
19417        }
19418      });
19419
19420      function inferTitleFromPath(value) {
19421        if (!value) return "project";
19422        var cleaned = value.replace(/[\/\\]+$/, "");
19423        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19424        return parts.length ? parts[parts.length - 1] : value;
19425      }
19426
19427      function updateReportTitleFromPath() {
19428        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19429        if (!reportTitleTouched) {
19430          reportTitleInput.value = inferred;
19431        }
19432        var title = reportTitleInput.value || inferred;
19433        if (liveReportTitle) liveReportTitle.textContent = title;
19434        if (reportTitlePreview) reportTitlePreview.textContent = title;
19435        document.title = "OxideSLOC | " + title;
19436
19437        var projectPath = (pathInput.value || "").trim();
19438        if (navProjectPill && navProjectTitle) {
19439          if (projectPath.length > 0) {
19440            navProjectTitle.textContent = inferred;
19441            navProjectPill.classList.add("visible");
19442          } else {
19443            navProjectTitle.textContent = "";
19444            navProjectPill.classList.remove("visible");
19445          }
19446        }
19447      }
19448
19449      function updateMixedPolicyUI() {
19450        var key = mixedLinePolicy.value || "code_only";
19451        var info = mixedPolicyInfo[key];
19452        document.getElementById("mixed-policy-description").textContent = info.description;
19453        document.getElementById("mixed-policy-example").textContent = info.example;
19454      }
19455
19456      function updatePythonDocstringUI() {
19457        var checked = !!pythonDocstrings.checked;
19458        document.getElementById("python-docstring-example").textContent = checked
19459          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
19460          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
19461        document.getElementById("python-docstring-live-help").textContent = checked
19462          ? "Enabled: docstrings contribute to comment-style totals."
19463          : "Disabled: docstrings are not counted as comment content.";
19464      }
19465
19466      function renderPresetChips(targetId, chips) {
19467        var target = document.getElementById(targetId);
19468        if (!target) return;
19469        target.innerHTML = (chips || []).map(function (chip) {
19470          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19471        }).join('');
19472      }
19473
19474      function updatePresetDescriptions() {
19475        var scanInfo = scanPresetInfo[scanPreset.value];
19476        if (!scanInfo) return;
19477        document.getElementById("scan-preset-description").textContent = scanInfo.description;
19478        document.getElementById("scan-preset-example").textContent = scanInfo.example;
19479        document.getElementById("scan-preset-note").textContent = scanInfo.note;
19480        renderPresetChips("scan-preset-summary", scanInfo.chips);
19481      }
19482
19483      function applyScanPreset() {
19484        var info = scanPresetInfo[scanPreset.value];
19485        if (!info || !info.apply) return;
19486        mixedLinePolicy.value = info.apply.mixed;
19487        pythonDocstrings.checked = !!info.apply.docstrings;
19488        document.getElementById("generated_file_detection").value = info.apply.generated;
19489        document.getElementById("minified_file_detection").value = info.apply.minified;
19490        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19491        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19492        document.getElementById("binary_file_behavior").value = info.apply.binary;
19493        updateMixedPolicyUI();
19494        updatePythonDocstringUI();
19495      }
19496
19497      function updateReview() {
19498        var scanSummary = document.getElementById("review-scan-summary");
19499        var countSummary = document.getElementById("review-count-summary");
19500        var artifactSummary = document.getElementById("review-artifact-summary");
19501        var outputSummary = document.getElementById("review-output-summary");
19502        var previewSummary = document.getElementById("review-preview-summary");
19503        var readinessSummary = document.getElementById("review-readiness-summary");
19504        var includeText = document.getElementById("include_globs").value.trim();
19505        var excludeText = document.getElementById("exclude_globs").value.trim();
19506        var sidePathPreview = document.getElementById("side-path-preview");
19507        var sideOutputPreview = document.getElementById("side-output-preview");
19508        var sideTitlePreview = document.getElementById("side-title-preview");
19509
19510        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19511        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19512        if (sideTitlePreview) {
19513          var rt = document.getElementById("report_title");
19514          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19515        }
19516
19517        scanSummary.innerHTML = ""
19518          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19519          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19520          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19521
19522        countSummary.innerHTML = ""
19523          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19524          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19525          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19526          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19527          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19528          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19529          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19530          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19531
19532        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19533
19534        outputSummary.innerHTML = ""
19535          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19536          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19537
19538        if (previewSummary) {
19539          if (GIT_MODE) {
19540            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>';
19541          } else {
19542          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19543          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19544          var statMap = {};
19545          statButtons.forEach(function (button) {
19546            var valueNode = button.querySelector('.scope-stat-value');
19547            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19548          });
19549          previewSummary.innerHTML = ''
19550            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19551            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19552            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19553            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19554            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19555            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19556
19557          if (readinessSummary) {
19558            readinessSummary.innerHTML = ''
19559              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19560              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19561              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19562          }
19563          } // end else (non-GIT_MODE)
19564        }
19565      }
19566
19567      function escapeHtml(value) {
19568        return String(value)
19569          .replace(/&/g, "&amp;")
19570          .replace(/</g, "&lt;")
19571          .replace(/>/g, "&gt;")
19572          .replace(/"/g, "&quot;")
19573          .replace(/'/g, "&#39;");
19574      }
19575
19576      function isPythonVisible() {
19577        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19578      }
19579
19580      function syncPythonVisibility() {
19581        var html = previewPanel.textContent || "";
19582        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19583        pythonWraps.forEach(function (node) {
19584          node.classList.toggle("hidden", !hasPython);
19585        });
19586      }
19587
19588      function attachPreviewInteractions() {
19589        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19590        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19591        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19592        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19593        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19594        var searchInput = previewPanel.querySelector("#explorer-search");
19595        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19596        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19597        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19598        var activeFilter = "all";
19599        var activeLanguage = "";
19600        var searchTerm = "";
19601        var currentSortKey = null;
19602        var currentSortOrder = "asc";
19603        var childRows = {};
19604
19605        rows.forEach(function (row) {
19606          var parentId = row.getAttribute("data-parent-id") || "";
19607          var rowId = row.getAttribute("data-row-id") || "";
19608          if (!childRows[parentId]) childRows[parentId] = [];
19609          childRows[parentId].push(rowId);
19610        });
19611
19612        function rowById(id) {
19613          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19614        }
19615
19616        function hasCollapsedAncestor(row) {
19617          var parentId = row.getAttribute("data-parent-id");
19618          while (parentId) {
19619            var parent = rowById(parentId);
19620            if (!parent) break;
19621            if (parent.getAttribute("data-expanded") === "false") return true;
19622            parentId = parent.getAttribute("data-parent-id");
19623          }
19624          return false;
19625        }
19626
19627        function updateToggleGlyph(row) {
19628          var toggle = row.querySelector(".tree-toggle");
19629          if (!toggle) return;
19630          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19631        }
19632
19633        function rowSortValue(row, key) {
19634          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19635        }
19636
19637        function updateSortButtons() {
19638          sortButtons.forEach(function (button) {
19639            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19640            var indicator = button.querySelector(".tree-sort-indicator");
19641            button.classList.toggle("active", isActive);
19642            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19643            if (indicator) {
19644              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19645            }
19646          });
19647        }
19648
19649        function sortSiblingRows() {
19650          if (!treeContainer) {
19651            updateSortButtons();
19652            return;
19653          }
19654
19655          var rowMap = {};
19656          var childrenMap = {};
19657          rows.forEach(function (row) {
19658            var rowId = row.getAttribute("data-row-id");
19659            var parentId = row.getAttribute("data-parent-id") || "";
19660            rowMap[rowId] = row;
19661            if (!childrenMap[parentId]) childrenMap[parentId] = [];
19662            childrenMap[parentId].push(rowId);
19663          });
19664
19665          Object.keys(childrenMap).forEach(function (parentId) {
19666            if (!parentId) return;
19667            childrenMap[parentId].sort(function (a, b) {
19668              var rowA = rowMap[a];
19669              var rowB = rowMap[b];
19670              if (!currentSortKey) {
19671                return Number(a) - Number(b);
19672              }
19673              var valueA = rowSortValue(rowA, currentSortKey);
19674              var valueB = rowSortValue(rowB, currentSortKey);
19675              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19676              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19677              var fallbackA = rowSortValue(rowA, "name");
19678              var fallbackB = rowSortValue(rowB, "name");
19679              if (fallbackA < fallbackB) return -1;
19680              if (fallbackA > fallbackB) return 1;
19681              return Number(a) - Number(b);
19682            });
19683          });
19684
19685          var orderedIds = [];
19686          function pushChildren(parentId) {
19687            (childrenMap[parentId] || []).forEach(function (childId) {
19688              orderedIds.push(childId);
19689              pushChildren(childId);
19690            });
19691          }
19692
19693          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19694            orderedIds.push(topId);
19695            pushChildren(topId);
19696          });
19697
19698          orderedIds.forEach(function (id) {
19699            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19700          });
19701          updateSortButtons();
19702        }
19703
19704        function updateLanguageButtons() {
19705          languageButtons.forEach(function (button) {
19706            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19707            var isActive = languageValue === activeLanguage;
19708            button.classList.toggle("active", isActive);
19709          });
19710        }
19711
19712        function rowSelfMatches(row) {
19713          var kind = row.getAttribute("data-kind");
19714          var status = row.getAttribute("data-status");
19715          var language = (row.getAttribute("data-language") || "").toLowerCase();
19716          var name = row.getAttribute("data-name-lower") || "";
19717          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19718          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19719          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19720          var passesLanguage = !activeLanguage || language === activeLanguage;
19721          return passesFilter && passesSearch && passesLanguage;
19722        }
19723
19724        function hasMatchingDescendant(rowId) {
19725          return (childRows[rowId] || []).some(function (childId) {
19726            var childRow = rowById(childId);
19727            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19728          });
19729        }
19730
19731        function rowMatches(row) {
19732          if (rowSelfMatches(row)) return true;
19733          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19734        }
19735
19736        function resetViewState() {
19737          activeFilter = "all";
19738          activeLanguage = "";
19739          searchTerm = "";
19740          currentSortKey = null;
19741          currentSortOrder = "asc";
19742          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19743          if (searchInput) searchInput.value = "";
19744          if (filterSelect) filterSelect.value = "all";
19745          updateLanguageButtons();
19746        }
19747
19748        function applyVisibility() {
19749          rows.forEach(function (row) {
19750            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19751            row.classList.toggle("hidden-by-filter", !visible);
19752            row.style.display = visible ? "grid" : "none";
19753          });
19754          buttons.forEach(function (button) {
19755            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19756          });
19757          if (filterSelect) filterSelect.value = activeFilter;
19758        }
19759
19760        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19761        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19762        var originalStats = {};
19763        buttons.forEach(function (btn) {
19764          var f = btn.getAttribute('data-filter');
19765          var v = btn.querySelector('.scope-stat-value');
19766          if (f && v) originalStats[f] = v.textContent;
19767        });
19768
19769        function applySubmoduleStats(statsJson) {
19770          try {
19771            var s = JSON.parse(statsJson);
19772            buttons.forEach(function (btn) {
19773              var f = btn.getAttribute('data-filter');
19774              var v = btn.querySelector('.scope-stat-value');
19775              if (!v) return;
19776              if (f === 'dir') v.textContent = s.dirs;
19777              else if (f === 'file') v.textContent = s.files;
19778              else if (f === 'supported') v.textContent = s.supported;
19779              else if (f === 'skipped') v.textContent = s.skipped;
19780              else if (f === 'unsupported') v.textContent = s.unsupported;
19781            });
19782          } catch (e) {}
19783        }
19784
19785        function restoreBaseRepoStats() {
19786          buttons.forEach(function (btn) {
19787            var f = btn.getAttribute('data-filter');
19788            var v = btn.querySelector('.scope-stat-value');
19789            if (v && originalStats[f]) v.textContent = originalStats[f];
19790          });
19791          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19792          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19793        }
19794
19795        submoduleChips.forEach(function (chip) {
19796          chip.addEventListener('click', function () {
19797            var statsJson = chip.getAttribute('data-sub-stats');
19798            if (!statsJson) return;
19799            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19800            chip.classList.add('active');
19801            applySubmoduleStats(statsJson);
19802            if (baseRepoBtn) baseRepoBtn.style.display = '';
19803          });
19804        });
19805
19806        if (baseRepoBtn) {
19807          baseRepoBtn.addEventListener('click', function () {
19808            restoreBaseRepoStats();
19809            resetViewState();
19810            sortSiblingRows();
19811            applyVisibility();
19812          });
19813        }
19814
19815        buttons.forEach(function (button) {
19816          button.addEventListener("click", function () {
19817            var filterValue = button.getAttribute("data-filter") || "all";
19818            if (filterValue === "reset-view") {
19819              restoreBaseRepoStats();
19820              resetViewState();
19821              sortSiblingRows();
19822              applyVisibility();
19823              return;
19824            }
19825            activeFilter = filterValue;
19826            applyVisibility();
19827          });
19828        });
19829
19830        rows.forEach(function (row) {
19831          updateToggleGlyph(row);
19832          var toggle = row.querySelector(".tree-toggle");
19833          if (toggle) {
19834            toggle.addEventListener("click", function () {
19835              var expanded = row.getAttribute("data-expanded") !== "false";
19836              row.setAttribute("data-expanded", expanded ? "false" : "true");
19837              updateToggleGlyph(row);
19838              applyVisibility();
19839            });
19840          }
19841        });
19842
19843        actionButtons.forEach(function (button) {
19844          button.addEventListener("click", function () {
19845            var action = button.getAttribute("data-explorer-action");
19846            if (action === "expand-all") {
19847              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19848            } else if (action === "collapse-all") {
19849              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19850            } else if (action === "clear-filters") {
19851              resetViewState();
19852            }
19853            sortSiblingRows();
19854            applyVisibility();
19855          });
19856        });
19857
19858        if (filterSelect) {
19859          filterSelect.addEventListener("change", function () {
19860            activeFilter = filterSelect.value || "all";
19861            applyVisibility();
19862          });
19863        }
19864
19865        languageButtons.forEach(function (button) {
19866          button.addEventListener("click", function () {
19867            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19868            updateLanguageButtons();
19869            applyVisibility();
19870          });
19871        });
19872
19873        sortButtons.forEach(function (button) {
19874          button.addEventListener("click", function () {
19875            var sortKey = button.getAttribute("data-sort-key");
19876            if (currentSortKey === sortKey) {
19877              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
19878            } else {
19879              currentSortKey = sortKey;
19880              currentSortOrder = "asc";
19881            }
19882            sortSiblingRows();
19883            applyVisibility();
19884          });
19885        });
19886
19887        if (searchInput) {
19888          searchInput.addEventListener("input", function () {
19889            searchTerm = searchInput.value.trim().toLowerCase();
19890            applyVisibility();
19891          });
19892        }
19893
19894        updateLanguageButtons();
19895        sortSiblingRows();
19896        applyVisibility();
19897      }
19898
19899      function loadPreview() {
19900        if (!previewPanel || !pathInput) return;
19901        if (GIT_MODE) {
19902          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>';
19903          setPreviewLoading(false);
19904          return;
19905        }
19906        var path = pathInput.value.trim();
19907        var zeroWarn = document.getElementById('zero-files-warning');
19908        if (!path) {
19909          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
19910          if (zeroWarn) zeroWarn.style.display = 'none';
19911          setPreviewLoading(false);
19912          return;
19913        }
19914        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
19915        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
19916        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
19917        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
19918        var myGen = ++_previewGen;
19919        var _prevMsgs = [
19920          'Scanning directory structure\u2026',
19921          'Detecting file types\u2026',
19922          'Applying include / exclude filters\u2026',
19923          'Estimating file counts\u2026',
19924          'Building scope preview\u2026',
19925          'Almost there\u2026'
19926        ];
19927        var _prevMsgIdx = 0;
19928        var _prevStart = Date.now();
19929        previewPanel.innerHTML =
19930          '<div class="preview-loading">' +
19931          '<div class="preview-spinner"></div>' +
19932          '<div class="preview-loading-text">' +
19933          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
19934          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
19935          '</div></div>';
19936        var _sizeTextEl = document.getElementById('project-size-text');
19937        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
19938        window._previewInterval = setInterval(function() {
19939          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
19940          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
19941          var ml = document.getElementById('plm');
19942          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
19943        }, 1500);
19944        window._previewElapsedTimer = setInterval(function() {
19945          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
19946          var el = document.getElementById('ple');
19947          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
19948        }, 1000);
19949        setPreviewLoading(true);
19950        var previewUrl = "/preview?path=" + encodeURIComponent(path)
19951          + "&include_globs=" + encodeURIComponent(includeValue)
19952          + "&exclude_globs=" + encodeURIComponent(excludeValue);
19953        fetch(previewUrl)
19954          .then(function (response) { return response.text(); })
19955          .then(function (html) {
19956            if (myGen !== _previewGen) return;
19957            clearInterval(window._previewInterval); window._previewInterval = null;
19958            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19959            setPreviewLoading(false);
19960            previewPanel.innerHTML = html;
19961            attachPreviewInteractions();
19962            syncPythonVisibility();
19963            updateReview();
19964            setTimeout(collapseLanguagePills, 50);
19965            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
19966            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
19967            var sizeText = document.getElementById('project-size-text');
19968            var sizeBtn = document.getElementById('project-size-btn');
19969            // In server mode with upload sizes available, keep the compressed/original pair.
19970            if (SERVER_MODE && window._lastUploadSizes) {
19971              var us = window._lastUploadSizes;
19972              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
19973                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
19974              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
19975                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
19976            } else if (sizeText && projectSize) {
19977              sizeText.textContent = 'Project size: ' + projectSize;
19978              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
19979            } else if (sizeText) {
19980              sizeText.textContent = 'Project size: \u2014';
19981            }
19982            if (zeroWarn) {
19983              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
19984              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
19985              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
19986              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
19987              if (supportedCount === 0 && fileCount > 0) {
19988                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).';
19989                zeroWarn.style.display = '';
19990              } else {
19991                zeroWarn.style.display = 'none';
19992              }
19993            }
19994          })
19995          .catch(function (err) {
19996            if (myGen !== _previewGen) return;
19997            clearInterval(window._previewInterval); window._previewInterval = null;
19998            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19999            setPreviewLoading(false);
20000            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20001          });
20002      }
20003
20004      function pickDirectory(targetInput, kind) {
20005        if (!targetInput) {
20006          showBannerToast("Directory picker: input element not found.", true);
20007          return;
20008        }
20009        if (SERVER_MODE) {
20010          if (kind === 'output') {
20011            showBannerToast(
20012              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20013              false,
20014              { top: true, icon: '\u{1F4C1}' }
20015            );
20016            return;
20017          }
20018          var inputEl = kind === 'coverage'
20019            ? document.getElementById('cov-upload-input')
20020            : document.getElementById('dir-upload-input');
20021          if (!inputEl) return;
20022          inputEl.onchange = function () {
20023            var files = inputEl.files;
20024            if (!files || files.length === 0) return;
20025            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20026            if (browseBtn) browseBtn.disabled = true;
20027
20028            function fileToBase64(file) {
20029              return new Promise(function (resolve, reject) {
20030                var reader = new FileReader();
20031                reader.onload = function () {
20032                  var b64 = reader.result.split(',')[1];
20033                  resolve(b64);
20034                };
20035                reader.onerror = reject;
20036                reader.readAsDataURL(file);
20037              });
20038            }
20039
20040            if (kind === 'coverage') {
20041              var f = files[0];
20042              if (previewPanel && targetInput === pathInput)
20043                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20044              fileToBase64(f).then(function (b64) {
20045                return fetch('/api/upload-file', {
20046                  method: 'POST',
20047                  headers: { 'Content-Type': 'application/json' },
20048                  body: JSON.stringify({ filename: f.name, content: b64 })
20049                }).then(function (r) { return r.json(); });
20050              })
20051                .then(function (d) {
20052                  if (d && d.tmp_path) {
20053                    if (coverageInput) coverageInput.value = d.tmp_path;
20054                    setCovStatus('idle');
20055                  } else if (d && d.error) { showBannerToast(d.error, true); }
20056                })
20057                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20058                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20059            } else {
20060              // ── Filter to source-code files only ─────────────────────────
20061              // Binary, generated, and dependency files (node_modules, .git,
20062              // build artifacts) are skipped so they are never uploaded.
20063              var CODE_EXTS = new Set([
20064                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20065                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20066                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20067                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20068                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20069                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20070                'tf','hcl','proto','thrift','avsc','graphql','gql'
20071              ]);
20072              var codeFiles = [];
20073              for (var i = 0; i < files.length; i++) {
20074                var f = files[i];
20075                var name = f.name;
20076                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20077                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20078                  codeFiles.push(f); continue;
20079                }
20080                var dot = name.lastIndexOf('.');
20081                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20082              }
20083              // Collect specific .git metadata files for server-side git detection.
20084              // These have no source extension so they are excluded by the loop above,
20085              // but the server needs them to read branch/commit/author without running git.
20086              var gitMetaFiles = [];
20087              for (var i = 0; i < files.length; i++) {
20088                var f = files[i];
20089                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20090                var gitIdx = rp.indexOf('/.git/');
20091                if (gitIdx < 0) continue;
20092                var gitRel = rp.slice(gitIdx + 1);
20093                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20094                    gitRel === '.git/logs/HEAD' ||
20095                    gitRel.startsWith('.git/refs/heads/') ||
20096                    gitRel.startsWith('.git/refs/tags/')) {
20097                  gitMetaFiles.push(f);
20098                }
20099              }
20100              var uploadFiles = codeFiles.concat(gitMetaFiles);
20101              var total = files.length;
20102              var kept = codeFiles.length;
20103              if (kept === 0) {
20104                if (previewPanel && targetInput === pathInput)
20105                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20106                if (browseBtn) browseBtn.disabled = false;
20107                inputEl.value = '';
20108                return;
20109              }
20110
20111              // ── Helper: apply upload result to UI ────────────────────────
20112              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20113              function applyUploadResult(tmpPath, sizes) {
20114                targetInput.value = tmpPath;
20115                scrollInputToEnd(targetInput);
20116                if (sizes && SERVER_MODE) {
20117                  window._lastUploadSizes = sizes;
20118                  // Immediately show both sizes before preview loads.
20119                  var sizeText = document.getElementById('project-size-text');
20120                  var sizeBtn = document.getElementById('project-size-btn');
20121                  if (sizeText) {
20122                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20123                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20124                  }
20125                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20126                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20127                }
20128                if (targetInput === pathInput) {
20129                  updateReportTitleFromPath();
20130                  autoSetOutputDir(tmpPath);
20131                  fetchProjectHistory(tmpPath);
20132                  loadPreview();
20133                  suggestCoverageFile(tmpPath);
20134                }
20135                updateReview();
20136                if (browseBtn) browseBtn.disabled = false;
20137                inputEl.value = '';
20138              }
20139
20140              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20141              if (typeof CompressionStream !== 'undefined') {
20142                if (previewPanel && targetInput === pathInput)
20143                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20144
20145                // Build a minimal POSIX ustar tar header for a single file entry.
20146                function buildUstarHeader(filePath, fileSize) {
20147                  var BLOCK = 512;
20148                  var hdr = new Uint8Array(BLOCK);
20149                  var enc = new TextEncoder();
20150                  function wStr(off, len, s) {
20151                    var b = enc.encode(s);
20152                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20153                  }
20154                  function wOct(off, len, val) {
20155                    var s = val.toString(8);
20156                    while (s.length < len - 1) s = '0' + s;
20157                    wStr(off, len, s + '\0');
20158                  }
20159                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20160                  var name = filePath, prefix = '';
20161                  if (filePath.length > 99) {
20162                    var split = filePath.lastIndexOf('/', 154);
20163                    if (split > 0 && filePath.length - split - 1 <= 99) {
20164                      prefix = filePath.substring(0, split);
20165                      name   = filePath.substring(split + 1);
20166                    } else { name = filePath.substring(0, 99); }
20167                  }
20168                  wStr(0,   100, name);          // name
20169                  wOct(100,   8, 0o000644);      // mode
20170                  wOct(108,   8, 0);             // uid
20171                  wOct(116,   8, 0);             // gid
20172                  wOct(124,  12, fileSize);      // size
20173                  wOct(136,  12, 0);             // mtime (epoch)
20174                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20175                  hdr[156] = 48;                 // type flag '0' = regular file
20176                  wStr(157, 100, '');            // linkname
20177                  wStr(257,   6, 'ustar');       // magic
20178                  wStr(263,   2, '00');          // version
20179                  wStr(265,  32, '');            // uname
20180                  wStr(297,  32, '');            // gname
20181                  wOct(329,   8, 0);             // devmajor
20182                  wOct(337,   8, 0);             // devminor
20183                  wStr(345, 155, prefix);        // prefix
20184                  // Compute checksum (sum of all bytes, placeholder = 32).
20185                  var chk = 0;
20186                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20187                  var cs = chk.toString(8);
20188                  while (cs.length < 6) cs = '0' + cs;
20189                  wStr(148, 8, cs + '\0 ');
20190                  return hdr;
20191                }
20192
20193                // Build tar.gz one file at a time, piping through CompressionStream.
20194                // RAM usage = compressed output buffer + one file at a time.
20195                (async function () {
20196                  try {
20197                    var BLOCK = 512;
20198                    var cs     = new CompressionStream('gzip');
20199                    var writer = cs.writable.getWriter();
20200                    var chunks = [];
20201                    var reader = cs.readable.getReader();
20202                    var collecting = (async function () {
20203                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20204                    })();
20205
20206                    for (var i = 0; i < uploadFiles.length; i++) {
20207                      var file = uploadFiles[i];
20208                      var path = file.webkitRelativePath || file.name;
20209                      var buf  = await file.arrayBuffer();
20210                      var data = new Uint8Array(buf);
20211                      // Header block
20212                      await writer.write(buildUstarHeader(path, data.length));
20213                      // Data padded to 512-byte boundary
20214                      if (data.length > 0) {
20215                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20216                        var block  = new Uint8Array(padded);
20217                        block.set(data);
20218                        await writer.write(block);
20219                      }
20220                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20221                        if (previewPanel && targetInput === pathInput)
20222                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20223                      }
20224                    }
20225                    // End-of-archive: two 512-byte zero blocks
20226                    await writer.write(new Uint8Array(BLOCK * 2));
20227                    await writer.close();
20228                    await collecting;
20229
20230                    var blob = new Blob(chunks, { type: 'application/gzip' });
20231                    var sizeMB = (blob.size / 1048576).toFixed(1);
20232                    if (previewPanel && targetInput === pathInput)
20233                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20234
20235                    var resp = await fetch('/api/upload-tarball', {
20236                      method: 'POST',
20237                      headers: { 'Content-Type': 'application/gzip' },
20238                      body: blob
20239                    });
20240                    var d = await resp.json();
20241                    if (d && d.tmp_path) {
20242                      applyUploadResult(d.tmp_path, {
20243                        compressed_bytes: d.compressed_bytes || 0,
20244                        original_bytes: d.original_bytes || 0
20245                      });
20246                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20247                  } catch (e) {
20248                    showBannerToast('Upload failed: ' + String(e), true);
20249                    if (browseBtn) browseBtn.disabled = false;
20250                    inputEl.value = '';
20251                  }
20252                })();
20253
20254              } else {
20255                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20256                // Used only on browsers that lack CompressionStream (pre-2023).
20257                var BATCH = 200;
20258                var batches = [];
20259                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20260                var totalBatches = batches.length;
20261                if (previewPanel && targetInput === pathInput)
20262                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20263
20264                function sendBatch(idx, currentUploadId, lastTmpPath) {
20265                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20266                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
20267                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20268                  Promise.all(batches[idx].map(function (file) {
20269                    return fileToBase64(file).then(function (b64) {
20270                      return { path: file.webkitRelativePath || file.name, content: b64 };
20271                    });
20272                  })).then(function (fileList) {
20273                    var body = { files: fileList };
20274                    if (currentUploadId) body.upload_id = currentUploadId;
20275                    return fetch('/api/upload-directory', {
20276                      method: 'POST', headers: { 'Content-Type': 'application/json' },
20277                      body: JSON.stringify(body)
20278                    }).then(function (r) { return r.json(); });
20279                  }).then(function (d) {
20280                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20281                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20282                  }).catch(function (e) {
20283                    showBannerToast('Upload failed: ' + String(e), true);
20284                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20285                  });
20286                }
20287                sendBatch(0, null, '');
20288              }
20289            }
20290          };
20291          inputEl.click();
20292          return;
20293        }
20294
20295        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20296        if (browseButton) browseButton.disabled = true;
20297
20298        if (previewPanel && targetInput === pathInput) {
20299          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20300        }
20301
20302        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
20303          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20304          .then(function (data) {
20305            if (data && data.selected_path) {
20306              targetInput.value = data.selected_path;
20307              scrollInputToEnd(targetInput);
20308
20309              if (targetInput === pathInput) {
20310                updateReportTitleFromPath();
20311                autoSetOutputDir(data.selected_path);
20312                fetchProjectHistory(data.selected_path);
20313                loadPreview();
20314                suggestCoverageFile(data.selected_path);
20315              }
20316
20317              updateReview();
20318            } else if (targetInput === pathInput) {
20319              loadPreview();
20320            }
20321          })
20322          .catch(function () {
20323            window.alert("Directory picker request failed.");
20324            if (previewPanel && targetInput === pathInput) {
20325              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20326            }
20327          })
20328          .finally(function () {
20329            if (browseButton) browseButton.disabled = false;
20330          });
20331      }
20332
20333      if (themeToggle) {
20334        themeToggle.addEventListener("click", function () {
20335          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20336          applyTheme(nextTheme);
20337          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20338        });
20339      }
20340
20341      stepButtons.forEach(function (button) {
20342        button.addEventListener("click", function () {
20343          var target = Number(button.getAttribute("data-step-target"));
20344          // Block jumping forward off step 1 while the preview / upload is running.
20345          if (previewLoading && currentStep === 1 && target > 1) return;
20346          setStep(target);
20347        });
20348      });
20349
20350      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20351        button.addEventListener("click", function () {
20352          var target = Number(button.getAttribute("data-step-target")) || 1;
20353          if (previewLoading && currentStep === 1 && target > 1) return;
20354          setStep(target);
20355        });
20356      });
20357
20358      // True when the project path is untouched from the bundled sample default.
20359      function isDefaultSamplePath() {
20360        return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20361      }
20362
20363      var defaultPathOverlay = document.getElementById("default-path-overlay");
20364      function closeDefaultPathModal() {
20365        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20366      }
20367      function openDefaultPathModal() {
20368        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20369      }
20370
20371      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20372        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20373        // that borrow the .next-step style class but carry no data-next target).
20374        if (!button.hasAttribute("data-next")) return;
20375        button.addEventListener("click", function () {
20376          // Guard step 1 → 2: block while the scope preview / upload is still running.
20377          if (button.getAttribute("data-next") === "2" && previewLoading) return;
20378          // Guard step 1 → 2: warn when the project path is still the sample default.
20379          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20380            openDefaultPathModal();
20381            return;
20382          }
20383          updateReview();
20384          setStep(Number(button.getAttribute("data-next")));
20385        });
20386      });
20387
20388      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20389        if (!button.hasAttribute("data-prev")) return;
20390        button.addEventListener("click", function () {
20391          setStep(Number(button.getAttribute("data-prev")));
20392        });
20393      });
20394
20395      // Default-sample-path confirmation modal wiring.
20396      var defaultPathProceed = document.getElementById("default-path-proceed");
20397      if (defaultPathProceed) {
20398        defaultPathProceed.addEventListener("click", function () {
20399          closeDefaultPathModal();
20400          updateReview();
20401          setStep(2);
20402        });
20403      }
20404      var defaultPathCancel = document.getElementById("default-path-cancel");
20405      if (defaultPathCancel) {
20406        defaultPathCancel.addEventListener("click", function () {
20407          closeDefaultPathModal();
20408          if (pathInput) { pathInput.focus(); pathInput.select(); }
20409        });
20410      }
20411      if (defaultPathOverlay) {
20412        defaultPathOverlay.addEventListener("click", function (e) {
20413          if (e.target === defaultPathOverlay) closeDefaultPathModal();
20414        });
20415      }
20416      document.addEventListener("keydown", function (e) {
20417        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20418          closeDefaultPathModal();
20419        }
20420      });
20421
20422      document.addEventListener("keydown", function (e) {
20423        var tag = (document.activeElement || {}).tagName || "";
20424        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20425        if (e.altKey || e.ctrlKey || e.metaKey) return;
20426        if (e.key === "ArrowRight" && currentStep < 4) {
20427          if (currentStep === 1 && previewLoading) return;
20428          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20429          updateReview(); setStep(currentStep + 1);
20430        }
20431        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20432      });
20433
20434      if (useSamplePath) {
20435        useSamplePath.addEventListener("click", function () {
20436          pathInput.value = "tests/fixtures/basic";
20437          updateReportTitleFromPath();
20438          autoSetOutputDir("tests/fixtures/basic");
20439          loadPreview();
20440          suggestCoverageFile("tests/fixtures/basic");
20441        });
20442      }
20443
20444      if (useDefaultOutput) {
20445        useDefaultOutput.addEventListener("click", function () {
20446          delete outputDirInput.dataset.userEdited;
20447          autoSetOutputDir(pathInput ? pathInput.value : "");
20448          updateReview();
20449        });
20450      }
20451
20452      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20453      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20454
20455      // ── Drag-and-drop directory upload (server mode only) ─────────────────
20456      // Dropping a folder onto the path field bypasses Chrome's
20457      // "Upload X files to this site?" confirmation dialog.
20458      async function readDirRecursively(dirEntry, basePath) {
20459        var reader = dirEntry.createReader();
20460        var all = [];
20461        for (;;) {
20462          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20463          if (!batch.length) break;
20464          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20465        }
20466        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20467        var out = [];
20468        for (var i = 0; i < all.length; i++) {
20469          var sub = all[i];
20470          if (sub.isFile) {
20471            var f = await new Promise(function(res) { sub.file(res); });
20472            out.push({ file: f, path: basePath + '/' + sub.name });
20473          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20474            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20475            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20476          }
20477        }
20478        return out;
20479      }
20480
20481      function setupPathDropZone() {
20482        if (!SERVER_MODE || !pathInput) return;
20483        var CODE_EXTS = new Set([
20484          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20485          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20486          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20487          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20488          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20489          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20490        ]);
20491        pathInput.addEventListener('dragover', function(e) {
20492          e.preventDefault();
20493          pathInput.classList.add('drag-over');
20494        });
20495        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20496        pathInput.addEventListener('drop', function(e) {
20497          e.preventDefault();
20498          pathInput.classList.remove('drag-over');
20499          var items = e.dataTransfer.items;
20500          if (!items || !items.length) return;
20501          var dirEntry = null;
20502          for (var i = 0; i < items.length; i++) {
20503            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20504            if (entry && entry.isDirectory) { dirEntry = entry; break; }
20505          }
20506          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20507          var btn = browsePath;
20508          if (btn) btn.disabled = true;
20509          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20510
20511          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20512            var total = allEntries.length;
20513            var codeEntries = allEntries.filter(function(e) {
20514              var n = e.file.name;
20515              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20516              var dot = n.lastIndexOf('.');
20517              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20518            });
20519            var kept = codeEntries.length;
20520            if (kept === 0) {
20521              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20522              if (btn) btn.disabled = false; return;
20523            }
20524
20525            function finish(tmpPath, sizes) {
20526              pathInput.value = tmpPath;
20527              scrollInputToEnd(pathInput);
20528              if (sizes) {
20529                window._lastUploadSizes = sizes;
20530                var sizeText = document.getElementById('project-size-text');
20531                var sizeBtn = document.getElementById('project-size-btn');
20532                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20533                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20534                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20535                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20536              }
20537              updateReportTitleFromPath();
20538              autoSetOutputDir(tmpPath);
20539              fetchProjectHistory(tmpPath);
20540              loadPreview();
20541              suggestCoverageFile(tmpPath);
20542              updateReview();
20543              if (btn) btn.disabled = false;
20544            }
20545
20546            if (typeof CompressionStream === 'undefined') {
20547              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20548              if (btn) btn.disabled = false; return;
20549            }
20550
20551            try {
20552              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20553              var BLOCK = 512;
20554              var cs = new CompressionStream('gzip');
20555              var wtr = cs.writable.getWriter();
20556              var chunks = [];
20557              var rdr = cs.readable.getReader();
20558              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20559
20560              function buildHdr(fp, sz) {
20561                var hdr = new Uint8Array(BLOCK);
20562                var enc = new TextEncoder();
20563                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]; }
20564                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20565                var nm = fp, pfx = '';
20566                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); } }
20567                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20568                for (var i = 148; i < 156; i++) hdr[i] = 32;
20569                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);
20570                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20571                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20572                return hdr;
20573              }
20574
20575              for (var i = 0; i < codeEntries.length; i++) {
20576                var ce = codeEntries[i];
20577                var buf = await ce.file.arrayBuffer();
20578                var data = new Uint8Array(buf);
20579                await wtr.write(buildHdr(ce.path, data.length));
20580                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20581                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20582                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20583              }
20584              await wtr.write(new Uint8Array(BLOCK * 2));
20585              await wtr.close();
20586              await collecting;
20587
20588              var blob = new Blob(chunks, { type: 'application/gzip' });
20589              var sizeMB = (blob.size / 1048576).toFixed(1);
20590              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20591              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20592              var d = await resp.json();
20593              if (d && d.tmp_path) {
20594                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20595              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20596            } catch (err) {
20597              showBannerToast('Upload failed: ' + String(err), true);
20598              if (btn) btn.disabled = false;
20599            }
20600          }).catch(function(err) {
20601            showBannerToast('Could not read folder: ' + String(err), true);
20602            if (btn) btn.disabled = false;
20603          });
20604        });
20605      }
20606      setupPathDropZone();
20607      if (browseCoverage) {
20608        browseCoverage.addEventListener("click", function () {
20609          pickDirectory(coverageInput || pathInput, "coverage");
20610        });
20611      }
20612
20613      function setCovStatus(state, opts) {
20614        if (!covScanStatus) return;
20615        opts = opts || {};
20616        covScanStatus.className = "cov-scan-status cov-scan-" + state;
20617        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20618        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>';
20619        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>';
20620        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>';
20621        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>';
20622        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20623        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20624        if (state === "scanning") {
20625          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20626        } else if (state === "found") {
20627          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20628          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20629          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20630          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20631        } else if (state === "hint") {
20632          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20633          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
20634          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>';
20635        } else if (state === "none") {
20636          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20637          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
20638        }
20639        html += '</div></div>';
20640        covScanStatus.innerHTML = html;
20641        if (state === "found") {
20642          var useBtn = covScanStatus.querySelector(".cov-scan-use");
20643          if (useBtn) useBtn.addEventListener("click", function () {
20644            if (coverageInput) coverageInput.value = "";
20645            covAutoFilled = false;
20646            setCovStatus("idle");
20647          });
20648        }
20649      }
20650
20651      function suggestCoverageFile(projectPath) {
20652        if (!coverageInput || !covScanStatus) return;
20653        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20654        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20655        clearTimeout(coverageSuggestTimer);
20656        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20657        setCovStatus("scanning");
20658        coverageSuggestTimer = setTimeout(function () {
20659          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20660            .then(function (r) { return r.json(); })
20661            .then(function (d) {
20662              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20663              if (!d) { setCovStatus("none"); return; }
20664              if (d.found) {
20665                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20666                setCovStatus("found", { found: d.found, tool: d.tool });
20667              } else if (d.tool && d.hint) {
20668                setCovStatus("hint", { tool: d.tool, hint: d.hint });
20669              } else {
20670                setCovStatus("none");
20671              }
20672            })
20673            .catch(function () { setCovStatus("idle"); });
20674        }, 600);
20675      }
20676
20677      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20678
20679      if (coverageInput) coverageInput.addEventListener("input", function () {
20680        covAutoFilled = false;
20681        if (!this.value.trim()) setCovStatus("idle");
20682      });
20683
20684      // ── Language pill overflow: collapse to "+N more" chip ─────────────
20685      function collapseLanguagePills() {
20686        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20687        rows.forEach(function(row) {
20688          // Remove any previous overflow chip
20689          var prev = row.querySelector('.lang-overflow-chip');
20690          if (prev) prev.remove();
20691          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20692          pills.forEach(function(p) { p.style.display = ''; });
20693          if (!pills.length) return;
20694
20695          // Measure after restoring all pills
20696          var containerRight = row.getBoundingClientRect().right;
20697          var hidden = [];
20698          for (var i = pills.length - 1; i >= 1; i--) {
20699            var rect = pills[i].getBoundingClientRect();
20700            if (rect.right > containerRight + 2) {
20701              hidden.unshift(pills[i]);
20702              pills[i].style.display = 'none';
20703            } else {
20704              break;
20705            }
20706          }
20707
20708          if (hidden.length) {
20709            var chip = document.createElement('button');
20710            chip.type = 'button';
20711            chip.className = 'language-pill lang-overflow-chip';
20712            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20713            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20714            row.appendChild(chip);
20715          }
20716        });
20717      }
20718
20719      // Run after preview loads (preview panel populates language pills)
20720      var _origLoadPreviewCb = window.__previewLoaded;
20721      document.addEventListener('previewLoaded', collapseLanguagePills);
20722      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20723      setTimeout(collapseLanguagePills, 400);
20724
20725      // ── Project history & output dir auto-set ──────────────────────────
20726      var wsOutputRoot   = document.getElementById("ws-output-root");
20727      var wsScanCount    = document.getElementById("ws-scan-count");
20728      var wsLastScan     = document.getElementById("ws-last-scan");
20729      var historyBadge   = document.getElementById("path-history-badge");
20730      var historyTimer   = null;
20731
20732      var wsOutputLink = document.getElementById("ws-output-link");
20733      function syncStripOutputRoot() {
20734        var val = outputDirInput ? outputDirInput.value : "";
20735        var display = val || "project/sloc";
20736        if (wsOutputRoot) wsOutputRoot.textContent = display;
20737        if (wsOutputLink) wsOutputLink.dataset.folder = val;
20738      }
20739
20740      function scrollInputToEnd(input) {
20741        if (!input) return;
20742        // Defer so the DOM has the new value before we measure scroll width.
20743        requestAnimationFrame(function () {
20744          input.scrollLeft = input.scrollWidth;
20745          input.selectionStart = input.selectionEnd = input.value.length;
20746        });
20747      }
20748
20749      function autoSetOutputDir(projectPath) {
20750        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20751        if (GIT_MODE && GIT_OUTPUT_DIR) {
20752          outputDirInput.value = GIT_OUTPUT_DIR;
20753          scrollInputToEnd(outputDirInput);
20754          syncStripOutputRoot();
20755          updateReview();
20756          return;
20757        }
20758        if (!projectPath || !projectPath.trim()) return;
20759        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20760        outputDirInput.value = cleaned + "/sloc";
20761        scrollInputToEnd(outputDirInput);
20762        syncStripOutputRoot();
20763        updateReview();
20764      }
20765
20766      var wsBranch = document.getElementById("ws-branch");
20767
20768      function fetchProjectHistory(projectPath) {
20769        if (!projectPath || !projectPath.trim()) {
20770          if (wsScanCount) wsScanCount.textContent = "\u2014";
20771          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
20772          if (wsBranch)    wsBranch.textContent    = "\u2014";
20773          if (historyBadge) historyBadge.style.display = "none";
20774          return;
20775        }
20776        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20777          .then(function (r) { return r.ok ? r.json() : null; })
20778          .then(function (data) {
20779            if (!data) return;
20780            var countStr = data.scan_count > 0
20781              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20782              : "never";
20783            var tsStr = data.last_scan_timestamp
20784              ? data.last_scan_timestamp.replace(" UTC","")
20785              : "\u2014";
20786            if (wsScanCount) wsScanCount.textContent = countStr;
20787            if (wsLastScan)  wsLastScan.textContent  = tsStr;
20788            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
20789            if (data.scan_count > 0) {
20790              if (historyBadge) {
20791                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20792                historyBadge.textContent = data.scan_count + " previous scan" +
20793                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20794                  "Last: " + (data.last_scan_timestamp || "\u2014") +
20795                  " \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.";
20796                historyBadge.className = "path-history-badge found";
20797                historyBadge.style.display = "";
20798              }
20799            } else {
20800              if (historyBadge) historyBadge.style.display = "none";
20801            }
20802          })
20803          .catch(function () {});
20804      }
20805
20806      function onPathChange() {
20807        var val = pathInput ? pathInput.value : "";
20808        // Discard stale upload sizes when the user edits the path manually.
20809        window._lastUploadSizes = null;
20810        updateReportTitleFromPath();
20811        autoSetOutputDir(val);
20812        updateSidebarSummary();
20813        clearTimeout(historyTimer);
20814        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20815        if (previewTimer) clearTimeout(previewTimer);
20816        previewTimer = setTimeout(loadPreview, 280);
20817        suggestCoverageFile(val);
20818      }
20819
20820      if (pathInput) {
20821        pathInput.addEventListener("input", onPathChange);
20822      }
20823
20824      if (outputDirInput) {
20825        outputDirInput.addEventListener("input", function () {
20826          outputDirInput.dataset.userEdited = "1";
20827          syncStripOutputRoot();
20828          updateReview();
20829        });
20830      }
20831
20832      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20833        if (!node) return;
20834        node.addEventListener("input", function () {
20835          updateReview();
20836          if (previewTimer) clearTimeout(previewTimer);
20837          previewTimer = setTimeout(loadPreview, 280);
20838        });
20839      });
20840
20841      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20842        var node = document.getElementById(id);
20843        if (node) node.addEventListener("change", updateReview);
20844      });
20845
20846      if (reportTitleInput) {
20847        reportTitleInput.addEventListener("input", function () {
20848          reportTitleTouched = reportTitleInput.value.trim().length > 0;
20849          updateReportTitleFromPath();
20850          updateReview();
20851        });
20852      }
20853
20854      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20855      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20856      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20857      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20858
20859      if (coverageInput) {
20860        coverageInput.addEventListener("input", function () {
20861          if (coverageInput.value.trim()) setCovStatus("idle");
20862        });
20863      }
20864
20865      if (form && loading && submitButton) {
20866        form.addEventListener("submit", function (e) {
20867          e.preventDefault();
20868          submitButton.disabled = true;
20869          submitButton.textContent = "Scanning...";
20870          startAsyncAnalysis(new FormData(form));
20871        });
20872      }
20873
20874      function openPath(folder) {
20875        if (!folder) return;
20876        fetch('/open-path?path=' + encodeURIComponent(folder))
20877          .then(function (r) { return r.json(); })
20878          .then(function (d) {
20879            if (d && d.server_mode_disabled)
20880              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20881          })
20882          .catch(function () {});
20883      }
20884
20885      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
20886        btn.addEventListener('click', function () {
20887          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
20888        });
20889      });
20890
20891      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
20892      if (wsOutputLink) {
20893        wsOutputLink.addEventListener('click', function () {
20894          openPath(wsOutputLink.dataset.folder || '');
20895        });
20896      }
20897
20898      loadSavedTheme();
20899      updateMixedPolicyUI();
20900      updatePythonDocstringUI();
20901      applyScanPreset();
20902      updatePresetDescriptions();
20903      applyArtifactPreset();
20904      updateReview();
20905      updateScrollProgress(); // initialise bar to 0% (step 1)
20906      window.addEventListener("scroll", updateScrollProgress, { passive: true });
20907      onPathChange();         // seed output dir, history badge, and preview from initial path
20908      updateStepNav(1);
20909
20910      // Restore step from URL hash on initial load (e.g., back-forward cache)
20911      (function() {
20912        var hashMatch = location.hash.match(/^#step([1-4])$/);
20913        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
20914      })();
20915
20916      (function randomizeWatermarks() {
20917        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
20918        if (!wms.length) return;
20919        var placed = [];
20920        function tooClose(top, left) {
20921          for (var i = 0; i < placed.length; i++) {
20922            var dt = Math.abs(placed[i][0] - top);
20923            var dl = Math.abs(placed[i][1] - left);
20924            if (dt < 16 && dl < 12) return true;
20925          }
20926          return false;
20927        }
20928        function pick(leftBand) {
20929          for (var attempt = 0; attempt < 50; attempt++) {
20930            var top = Math.random() * 88 + 2;
20931            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20932            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20933          }
20934          var top = Math.random() * 88 + 2;
20935          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20936          placed.push([top, left]);
20937          return [top, left];
20938        }
20939        var half = Math.floor(wms.length / 2);
20940        wms.forEach(function (img, i) {
20941          var pos = pick(i < half);
20942          var size = Math.floor(Math.random() * 80 + 110);
20943          var rot = (Math.random() * 360).toFixed(1);
20944          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
20945          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;
20946        });
20947      })();
20948
20949      (function spawnCodeParticles() {
20950        var container = document.getElementById('code-particles');
20951        if (!container) return;
20952        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'];
20953        for (var i = 0; i < 38; i++) {
20954          (function(idx) {
20955            var el = document.createElement('span');
20956            el.className = 'code-particle';
20957            el.textContent = snippets[idx % snippets.length];
20958            var left = Math.random() * 94 + 2;
20959            var top = Math.random() * 88 + 6;
20960            var dur = (Math.random() * 10 + 9).toFixed(1);
20961            var delay = (Math.random() * 18).toFixed(1);
20962            var rot = (Math.random() * 26 - 13).toFixed(1);
20963            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20964            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';
20965            container.appendChild(el);
20966          })(i);
20967        }
20968      })();
20969    })();
20970  </script>
20971  <script nonce="{{ csp_nonce }}">
20972    (function () {
20973      var raw = {{ prefill_json|safe }};
20974      if (!raw || typeof raw !== 'object' || !raw.path) return;
20975      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
20976      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
20977      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
20978      setVal('path', raw.path || '');
20979      setVal('include_globs', raw.include_globs || '');
20980      setVal('exclude_globs', raw.exclude_globs || '');
20981      setVal('output_dir', raw.output_dir || '');
20982      setVal('report_title', raw.report_title || '');
20983      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
20984      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
20985      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
20986      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
20987      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
20988      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
20989      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
20990      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
20991      setChecked('generate_html', raw.generate_html !== false);
20992      setChecked('generate_pdf', !!raw.generate_pdf);
20993      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
20994      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
20995      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
20996      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
20997      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
20998      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
20999      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21000      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21001      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21002      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21003      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21004      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21005      // Trigger dynamic UI updates after pre-fill.
21006      setTimeout(function () {
21007        var pathEl = document.getElementById('path');
21008        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21009        var policyEl = document.getElementById('mixed_line_policy');
21010        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21011      }, 80);
21012    })();
21013  </script>
21014  <script nonce="{{ csp_nonce }}">
21015  (function(){
21016    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'}];
21017    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);});}
21018    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21019    function init(){
21020      var btn=document.getElementById('settings-btn');if(!btn)return;
21021      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21022      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>';
21023      document.body.appendChild(m);
21024      var g=document.getElementById('scheme-grid');
21025      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);});
21026      var cl=document.getElementById('settings-close');
21027      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);
21028      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');});
21029      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21030      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21031    }
21032    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21033  }());
21034  </script>
21035  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21036    <div class="wb-ftip-arrow"></div>
21037    <span id="wb-ftip-text"></span>
21038  </div>
21039  <script nonce="{{ csp_nonce }}">(function(){
21040    var tip=document.getElementById('wb-ftip');
21041    var txt=document.getElementById('wb-ftip-text');
21042    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21043    if(!tip||!txt)return;
21044    function pos(el){
21045      var r=el.getBoundingClientRect();
21046      tip.style.display='block';
21047      var tw=tip.offsetWidth;
21048      var lx=r.left+r.width/2-tw/2;
21049      if(lx<8)lx=8;
21050      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21051      tip.style.left=lx+'px';
21052      tip.style.top=(r.bottom+8)+'px';
21053      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';}
21054    }
21055    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21056      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21057      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21058    });
21059    window.addEventListener('blur',function(){tip.style.display='none';});
21060    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21061  })();
21062  (function(){
21063    function fixArtifactHintSpacing(){
21064      var grid=document.querySelector('.artifact-grid');
21065      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21066    }
21067    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21068  }());
21069  (function(){
21070    var dot=document.getElementById('status-dot');
21071    var pingEl=document.getElementById('server-ping-ms');
21072    var tipEl=document.getElementById('server-tip-ping');
21073    var fm=document.getElementById('footer-mode');
21074    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)';}}
21075    function doPing(){
21076      var t0=performance.now();
21077      fetch('/healthz',{cache:'no-store'})
21078        .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);})
21079        .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)';}});
21080    }
21081    doPing();
21082    setInterval(doPing,5000);
21083    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');}
21084  })();
21085  </script>
21086  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21087  <footer class="site-footer">
21088    local code analysis - metrics, history and reports
21089    &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>
21090    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21091    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21092    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21093    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21094  </footer>
21095</body>
21096</html>
21097"##,
21098    ext = "html"
21099)]
21100struct IndexTemplate {
21101    version: &'static str,
21102    prefill_json: String,
21103    csp_nonce: String,
21104    git_repo: String,
21105    git_ref: String,
21106    git_label_json: String,
21107    git_output_dir_json: String,
21108    server_mode: bool,
21109}
21110
21111// ── SplashTemplate ────────────────────────────────────────────────────────────
21112
21113#[derive(Template)]
21114#[template(
21115    source = r##"
21116<!doctype html>
21117<html lang="en">
21118<head>
21119  <meta charset="utf-8">
21120  <meta name="viewport" content="width=device-width, initial-scale=1">
21121  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21122  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21123  <script type="application/ld+json">
21124  {
21125    "@context": "https://schema.org",
21126    "@type": "SoftwareApplication",
21127    "name": "oxide-sloc",
21128    "applicationCategory": "DeveloperApplication",
21129    "operatingSystem": "Windows, Linux",
21130    "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.",
21131    "softwareVersion": "{{ version }}",
21132    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21133    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21134    "url": "https://github.com/oxide-sloc/oxide-sloc",
21135    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21136    "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",
21137    "programmingLanguage": "Rust",
21138    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21139  }
21140  </script>
21141  <style nonce="{{ csp_nonce }}">
21142    :root {
21143      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21144      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21145      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21146      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21147      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21148    }
21149    body.dark-theme {
21150      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21151      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21152    }
21153    *{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;}
21154    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21155    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21156    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21157    .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;}
21158    @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));}}
21159    .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);}
21160    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21161    .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));}
21162    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21163    .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;}
21164    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21165    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21166    @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; } }
21167    .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;}
21168    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21169    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21170    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21171    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21172    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21173    .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;}
21174    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21175    .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);}
21176    .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;}
21177    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21178    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21179    .settings-modal-body{padding:14px 16px 16px;}
21180    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21181    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21182    .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;}
21183    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21184    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21185    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21186    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21187    .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;}
21188    .tz-select:focus{border-color:var(--oxide);}
21189    .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;}
21190    .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;}
21191    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21192    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21193    .hero{text-align:center;margin:0 auto 18px;}
21194    .hero-logo-wrap{display:inline-block;cursor:default;}
21195    .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;}
21196    .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;}
21197    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21198    .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;}
21199    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%);}
21200    .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;
21201      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21202      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21203      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;}
21204    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21205    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21206    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;}
21207    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21208    .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;}
21209    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21210    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21211    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21212    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21213    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21214    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21215    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21216    .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;}
21217    .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;}
21218    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21219    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21220    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21221    .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);}
21222    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21223    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21224    .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);}
21225    .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);}
21226    .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);}
21227    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21228    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21229    .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;}
21230    body.dark-theme .action-card-cta{color:var(--oxide);}
21231    .action-card.view .action-card-cta{color:var(--accent-2);}
21232    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21233    .action-card.compare .action-card-cta{color:#7c3aed;}
21234    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21235    .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);}
21236    .action-card.git-tools .action-card-cta{color:#15803d;}
21237    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21238    .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);}
21239    .action-card.trend .action-card-cta{color:#0e7490;}
21240    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21241    .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);}
21242    .action-card.automation .action-card-cta{color:#b45309;}
21243    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21244    .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);}
21245    .action-card.test-metrics .action-card-cta{color:#be185d;}
21246    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21247    .action-card:hover .action-card-cta{gap:12px;}
21248    .action-card.card-split{flex-direction:row;align-items:stretch;}
21249    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21250    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21251    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21252    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21253    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21254    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21255    .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;}
21256    .ac-badge.active{opacity:1;}
21257    .ac-badge.github{border-color:#555;color:#555;}
21258    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21259    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21260    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21261    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21262    body.dark-theme .ac-right-row{color:var(--muted);}
21263    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21264    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21265    .divider{height:1px;background:var(--line);margin:32px 0;}
21266    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21267    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21268    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21269    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21270      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21271    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21272    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21273    body.dark-theme .info-chip-val{color:var(--oxide);}
21274    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21275    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21276      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21277      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21278    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21279      border:6px solid transparent;border-top-color:var(--text);}
21280    .info-chip:hover .info-chip-tip{display:block;}
21281    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21282    .chip-slide.fading{filter:blur(5px);opacity:0;}
21283    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21284    .site-footer a{color:var(--muted);}
21285    .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;}
21286    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21287    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21288    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21289    .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;}
21290    .lan-badge.local{background:var(--oxide-2);}
21291    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21292    .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);}
21293    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21294    .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;}
21295    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21296    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21297    .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;}
21298    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21299    .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;}
21300    .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);}
21301    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21302    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21303    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21304    .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;}
21305    @media (max-height: 1100px) {
21306      .page{padding-top:10px;}
21307      .hero{margin-bottom:10px;}
21308      .hero-logo{width:54px;height:60px;}
21309      .hero-logo-shadow{width:42px;}
21310      .hero-title{font-size:28px;}
21311      .hero-subtitle{font-size:13px;}
21312      .card-sections{gap:12px;margin-bottom:6px;}
21313      .card-section-grid-2,.card-section-grid-3{gap:10px;}
21314      .action-card{padding:8px 15px 8px;}
21315      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21316      .action-card-icon svg{width:18px;height:18px;}
21317      .action-card-title{font-size:13px;}
21318      .action-card-desc{font-size:11px;margin-bottom:6px;}
21319      .action-card-cta{font-size:11px;}
21320      .ac-right-row{font-size:11px;}
21321      .divider{margin:14px 0;}
21322      .info-strip{gap:7px;margin-bottom:8px;}
21323      .info-chip{padding:7px 10px;}
21324      .info-chip-val{font-size:13px;}
21325      .info-chip-label{font-size:9px;}
21326      .site-footer{padding:8px 24px;font-size:12px;}
21327      .lan-local-hint{margin-top:8px;}
21328    }
21329    @media (max-height: 850px) {
21330      .page{padding-top:6px;}
21331      .hero{margin-bottom:6px;}
21332      .hero-logo{width:42px;height:46px;}
21333      .hero-title{font-size:22px;}
21334      .hero-subtitle{font-size:12px;}
21335      .card-sections{gap:10px;}
21336      .action-card-desc{margin-bottom:4px;}
21337      .divider{margin:8px 0;}
21338      .info-strip{margin-bottom:6px;}
21339      .lan-local-hint{margin-top:10px;}
21340    }
21341  </style>
21342</head>
21343<body>
21344  <div class="background-watermarks" aria-hidden="true">
21345    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21346    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21347    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21348    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21349    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21350    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21351    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21352  </div>
21353  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21354  <div class="top-nav">
21355    <div class="top-nav-inner">
21356      <a class="brand" href="/">
21357        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21358        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21359      </a>
21360      <div class="nav-right">
21361        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21362        <div class="nav-dropdown">
21363          <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>
21364          <div class="nav-dropdown-menu">
21365            <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>
21366          </div>
21367        </div>
21368        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21369        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21370        <div class="nav-dropdown">
21371          <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>
21372          <div class="nav-dropdown-menu">
21373            <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>
21374          </div>
21375        </div>
21376        <div class="server-status-wrap" id="server-status-wrap">
21377          <div class="nav-pill server-online-pill" id="server-status-pill">
21378            <span class="status-dot" id="status-dot"></span>
21379            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21380            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21381          </div>
21382          <div class="server-status-tip">
21383            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21384            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21385          </div>
21386        </div>
21387        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21388          <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>
21389        </button>
21390        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21391          <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>
21392          <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>
21393        </button>
21394      </div>
21395    </div>
21396  </div>
21397
21398  <div class="page">
21399    <div class="hero">
21400      <div class="hero-logo-wrap" id="hero-logo-wrap">
21401        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21402      </div>
21403      <div class="hero-logo-shadow"></div>
21404      <div class="hero-title-wrap">
21405        <div class="hero-title-aura" aria-hidden="true"></div>
21406        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21407      </div>
21408      <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>
21409    </div>
21410
21411    <div class="card-sections">
21412
21413      <div>
21414        <div class="card-section-label">Analysis</div>
21415        <div class="card-section-grid-2">
21416          <a class="action-card scan card-split" href="/scan-setup">
21417            <div class="action-card-left">
21418              <div class="action-card-icon">
21419                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21420              </div>
21421              <div class="action-card-title">Scan Project</div>
21422              <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>
21423              <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>
21424            </div>
21425            <div class="action-card-sep"></div>
21426            <div class="action-card-right">
21427              <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>
21428              <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>
21429              <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>
21430              <div class="ac-right-stat" id="acp-scan-stat"></div>
21431            </div>
21432          </a>
21433          <a class="action-card test-metrics card-split" href="/test-metrics">
21434            <div class="action-card-left">
21435              <div class="action-card-icon">
21436                <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>
21437              </div>
21438              <div class="action-card-title">Test Metrics</div>
21439              <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>
21440              <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>
21441            </div>
21442            <div class="action-card-sep"></div>
21443            <div class="action-card-right">
21444              <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>
21445              <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>
21446              <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>
21447              <div class="ac-right-stat" id="acp-test-stat"></div>
21448            </div>
21449          </a>
21450        </div>
21451      </div>
21452
21453      <div>
21454        <div class="card-section-label">Reports &amp; Insights</div>
21455        <div class="card-section-grid-3">
21456          <a class="action-card view" href="/view-reports">
21457            <div class="action-card-icon">
21458              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21459            </div>
21460            <div class="action-card-title">View Reports</div>
21461            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21462            <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>
21463          </a>
21464          <a class="action-card compare" href="/compare-scans">
21465            <div class="action-card-icon">
21466              <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>
21467            </div>
21468            <div class="action-card-title">Compare Scans</div>
21469            <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>
21470            <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>
21471          </a>
21472          <a class="action-card trend" href="/trend-reports">
21473            <div class="action-card-icon">
21474              <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>
21475            </div>
21476            <div class="action-card-title">Trend Report</div>
21477            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21478            <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>
21479          </a>
21480        </div>
21481      </div>
21482
21483      <div>
21484        <div class="card-section-label">Developer Tools</div>
21485        <div class="card-section-grid-2">
21486          <a class="action-card git-tools card-split" href="/git-browser">
21487            <div class="action-card-left">
21488              <div class="action-card-icon">
21489                <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>
21490              </div>
21491              <div class="action-card-title">Git Browser</div>
21492              <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>
21493              <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>
21494            </div>
21495            <div class="action-card-sep"></div>
21496            <div class="action-card-right">
21497              <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>
21498              <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>
21499              <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>
21500            </div>
21501          </a>
21502          <a class="action-card automation card-split" href="/integrations">
21503            <div class="action-card-left">
21504              <div class="action-card-icon">
21505                <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>
21506              </div>
21507              <div class="action-card-title">Integrations</div>
21508              <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>
21509              <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>
21510            </div>
21511            <div class="action-card-sep"></div>
21512            <div class="action-card-right">
21513              <div class="ac-badges-grid">
21514                <span class="ac-badge github"     id="acp-gh">GitHub</span>
21515                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
21516                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
21517                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21518              </div>
21519              <div class="ac-right-stat" id="acp-int-stat"></div>
21520            </div>
21521          </a>
21522        </div>
21523      </div>
21524
21525    </div>
21526
21527    {% if server_mode %}
21528    <div class="lan-card server">
21529      <div class="lan-card-header">
21530        <span class="lan-badge">LAN server</span>
21531        Accessible on your network
21532      </div>
21533      {% if let Some(ip) = lan_ip %}
21534      <div class="lan-url-row">
21535        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21536        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21537          <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>
21538          Copy URL
21539        </button>
21540      </div>
21541      <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>
21542      {% if has_api_key %}
21543      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
21544      {% endif %}
21545      {% else %}
21546      <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>
21547      {% endif %}
21548    </div>
21549    {% endif %}
21550
21551    <div class="divider"></div>
21552
21553    <div class="info-strip">
21554      <div class="info-chip">
21555        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21556        <div class="chip-slide">
21557          <div class="info-chip-val">60</div>
21558          <div class="info-chip-label">Languages</div>
21559        </div>
21560      </div>
21561      <div class="info-chip">
21562        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21563        <div class="chip-slide">
21564          <div class="info-chip-val">100%</div>
21565          <div class="info-chip-label">Self-contained</div>
21566        </div>
21567      </div>
21568      <div class="info-chip">
21569        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21570        <div class="chip-slide">
21571          <div class="info-chip-val">HTML+PDF</div>
21572          <div class="info-chip-label">Exportable reports</div>
21573        </div>
21574      </div>
21575      <div class="info-chip">
21576        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21577        <div class="chip-slide">
21578          <div class="info-chip-val">Webhook</div>
21579          <div class="info-chip-label">3 platforms</div>
21580        </div>
21581      </div>
21582      <div class="info-chip">
21583        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21584        <div class="chip-slide">
21585          <div class="info-chip-val">IEEE</div>
21586          <div class="info-chip-label">1045-1992</div>
21587        </div>
21588      </div>
21589    </div>
21590
21591    {% if lan_ip.is_none() %}
21592    <div class="lan-local-hint">
21593      <strong>Want teammates on the same network to access this?</strong><br>
21594      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
21595    </div>
21596    {% endif %}
21597  </div>
21598
21599  <footer class="site-footer">
21600    local code analysis - metrics, history and reports
21601    &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>
21602    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21603    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21604    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21605    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21606  </footer>
21607
21608  <script nonce="{{ csp_nonce }}">
21609    (function () {
21610      var storageKey = 'oxide-sloc-theme';
21611      var body = document.body;
21612      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21613      var toggle = document.getElementById('theme-toggle');
21614      if (toggle) toggle.addEventListener('click', function () {
21615        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21616        body.classList.toggle('dark-theme', next === 'dark');
21617        try { localStorage.setItem(storageKey, next); } catch(e) {}
21618      });
21619      var copyBtn = document.getElementById('lan-copy-btn');
21620      if (copyBtn) copyBtn.addEventListener('click', function() {
21621        var btn = this;
21622        var el = document.getElementById('lan-url-val');
21623        if (!el) return;
21624        var url = el.textContent.trim();
21625        if (navigator.clipboard) {
21626          navigator.clipboard.writeText(url).then(function() {
21627            var orig = btn.innerHTML;
21628            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!';
21629            setTimeout(function() { btn.innerHTML = orig; }, 1800);
21630          });
21631        }
21632      });
21633      (function randomizeWatermarks() {
21634        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21635        if (!wms.length) return;
21636        var placed = [];
21637        function tooClose(top, left) {
21638          for (var i = 0; i < placed.length; i++) {
21639            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21640            if (dt < 16 && dl < 12) return true;
21641          }
21642          return false;
21643        }
21644        function pick(leftBand) {
21645          for (var attempt = 0; attempt < 50; attempt++) {
21646            var top = Math.random() * 88 + 2;
21647            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21648            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21649          }
21650          var top = Math.random() * 88 + 2;
21651          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21652          placed.push([top, left]); return [top, left];
21653        }
21654        var half = Math.floor(wms.length / 2);
21655        wms.forEach(function (img, i) {
21656          var pos = pick(i < half);
21657          var size = Math.floor(Math.random() * 100 + 120);
21658          var rot = (Math.random() * 360).toFixed(1);
21659          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21660          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;
21661        });
21662      })();
21663
21664      (function spawnCodeParticles() {
21665        var container = document.getElementById('code-particles');
21666        if (!container) return;
21667        var snippets = [
21668          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21669          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21670          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21671          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21672          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21673        ];
21674        var count = 38;
21675        for (var i = 0; i < count; i++) {
21676          (function(idx) {
21677            var el = document.createElement('span');
21678            el.className = 'code-particle';
21679            var text = snippets[idx % snippets.length];
21680            el.textContent = text;
21681            var left = Math.random() * 94 + 2;
21682            var top = Math.random() * 88 + 6;
21683            var dur = (Math.random() * 10 + 9).toFixed(1);
21684            var delay = (Math.random() * 18).toFixed(1);
21685            var rot = (Math.random() * 26 - 13).toFixed(1);
21686            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21687            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21688              + '--rot:' + rot + 'deg;--op:' + op + ';'
21689              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21690            container.appendChild(el);
21691          })(i);
21692        }
21693      })();
21694      (function heroAnimations() {
21695        var sub = document.getElementById('hero-subtitle');
21696        if (sub) {
21697          var full = sub.textContent.trim();
21698          sub.textContent = '';
21699          sub.style.opacity = '1';
21700          var cursor = document.createElement('span');
21701          cursor.className = 'hero-cursor';
21702          sub.appendChild(cursor);
21703          var i = 0;
21704          setTimeout(function() {
21705            var iv = setInterval(function() {
21706              if (i < full.length) {
21707                sub.insertBefore(document.createTextNode(full[i]), cursor);
21708                i++;
21709              } else {
21710                clearInterval(iv);
21711                setTimeout(function() {
21712                  cursor.style.transition = 'opacity 1s ease';
21713                  cursor.style.opacity = '0';
21714                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21715                }, 2400);
21716              }
21717            }, 11);
21718          }, 374);
21719        }
21720      })();
21721      (function logoBob() {
21722        var logo = document.querySelector('.hero-logo');
21723        var shadow = document.querySelector('.hero-logo-shadow');
21724        if (!logo) return;
21725        var cycleStart = null, cycleDur = 3600;
21726        var peakY = -14, peakScale = 1.07, peakRot = 0;
21727        function newCycle() {
21728          cycleDur = 3000 + Math.random() * 1840;
21729          peakY = -(9 + Math.random() * 13.8);
21730          peakScale = 1.04 + Math.random() * 0.081;
21731          peakRot = (Math.random() * 11.5 - 5.75);
21732        }
21733        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21734        newCycle();
21735        function frame(ts) {
21736          if (cycleStart === null) cycleStart = ts;
21737          var t = (ts - cycleStart) / cycleDur;
21738          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21739          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21740          var y = peakY * phase;
21741          var sc = 1 + (peakScale - 1) * phase;
21742          var rot = peakRot * Math.sin(Math.PI * phase);
21743          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21744          if (shadow) {
21745            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21746            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21747          }
21748          requestAnimationFrame(frame);
21749        }
21750        requestAnimationFrame(frame);
21751      })();
21752      (function mouseEffects() {
21753        var heroTitle = document.getElementById('hero-title');
21754        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21755        function tick() {
21756          raf = null;
21757          if (heroTitle) {
21758            var r = heroTitle.getBoundingClientRect();
21759            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21760            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21761            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21762          }
21763        }
21764        document.addEventListener('mousemove', function(e) {
21765          mx = e.clientX; my = e.clientY;
21766          if (!raf) raf = requestAnimationFrame(tick);
21767        });
21768        document.addEventListener('mouseleave', function() {
21769          if (heroTitle) {
21770            heroTitle.style.transition = 'transform 0.5s ease';
21771            heroTitle.style.transform = '';
21772            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21773          }
21774        });
21775        document.querySelectorAll('.action-card').forEach(function(card) {
21776          card.addEventListener('mousemove', function(e) {
21777            var rect = card.getBoundingClientRect();
21778            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21779            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21780            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21781            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21782          });
21783          card.addEventListener('mouseleave', function() {
21784            card.style.transition = '';
21785            card.style.transform = '';
21786          });
21787        });
21788      })();
21789      (function chipSlideshow() {
21790        var slides = [
21791          [{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'}],
21792          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21793          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21794          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21795          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21796        ];
21797        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21798        var indices = [0,0,0,0,0];
21799        var paused = [false,false,false,false,false];
21800        chips.forEach(function(chip, i) {
21801          chip.addEventListener('mouseenter', function() { paused[i] = true; });
21802          chip.addEventListener('mouseleave', function() { paused[i] = false; });
21803        });
21804        function advance(i) {
21805          if (paused[i]) return;
21806          var chip = chips[i];
21807          var inner = chip.querySelector('.chip-slide');
21808          if (!inner) return;
21809          inner.classList.add('fading');
21810          setTimeout(function() {
21811            indices[i] = (indices[i] + 1) % slides[i].length;
21812            var s = slides[i][indices[i]];
21813            chip.querySelector('.info-chip-val').textContent = s.v;
21814            chip.querySelector('.info-chip-label').textContent = s.l;
21815            inner.classList.remove('fading');
21816          }, 720);
21817        }
21818        setInterval(function() {
21819          chips.forEach(function(chip, i) { advance(i); });
21820        }, 6000);
21821      })();
21822      (function cardLiveData() {
21823        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21824          var el = document.getElementById('acp-scan-stat');
21825          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21826        }).catch(function(){});
21827        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21828          var el = document.getElementById('acp-test-stat');
21829          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21830        }).catch(function(){});
21831        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21832          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21833          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21834          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21835          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21836          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21837          var stat = document.getElementById('acp-int-stat');
21838          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21839        }).catch(function(){});
21840        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21841          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21842        }).catch(function(){});
21843      })();
21844    })();
21845  </script>
21846  <script nonce="{{ csp_nonce }}">
21847  (function(){
21848    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'}];
21849    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);});}
21850    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21851    function init(){
21852      var btn=document.getElementById('settings-btn');if(!btn)return;
21853      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21854      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>';
21855      document.body.appendChild(m);
21856      var g=document.getElementById('scheme-grid');
21857      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);});
21858      var cl=document.getElementById('settings-close');
21859      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);
21860      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');});
21861      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21862      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21863    }
21864    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21865  }());
21866  </script>
21867  <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>
21868</body>
21869</html>
21870"##,
21871    ext = "html"
21872)]
21873struct SplashTemplate {
21874    csp_nonce: String,
21875    server_mode: bool,
21876    lan_ip: Option<String>,
21877    port: u16,
21878    version: &'static str,
21879    has_api_key: bool,
21880}
21881
21882// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
21883
21884#[derive(Template)]
21885#[template(
21886    source = r##"
21887<!doctype html>
21888<html lang="en">
21889<head>
21890  <meta charset="utf-8">
21891  <meta name="viewport" content="width=device-width, initial-scale=1">
21892  <title>OxideSLOC — Start a Scan</title>
21893  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21894  <style nonce="{{ csp_nonce }}">
21895    :root {
21896      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
21897      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21898      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21899      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21900      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21901    }
21902    body.dark-theme {
21903      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21904      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21905    }
21906    *{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;}
21907    .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);}
21908    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21909    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
21910    .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));}
21911    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21912    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21913    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
21914    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21915    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21916    @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; } }
21917    .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;}
21918    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21919    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21920    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21921    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21922    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21923    .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;}
21924    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21925    .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);}
21926    .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;}
21927    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21928    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21929    .settings-modal-body{padding:14px 16px 16px;}
21930    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21931    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21932    .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;}
21933    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21934    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21935    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21936    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21937    .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;}
21938    .tz-select:focus{border-color:var(--oxide);}
21939    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21940    .page-header{text-align:center;margin-bottom:16px;}
21941    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
21942    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
21943    /* Cards */
21944    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
21945    .option-card-wrap{position:relative;}
21946    .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;}
21947    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
21948    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21949    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
21950    .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;}
21951    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21952    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
21953    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
21954    .card-top-row{display:flex;align-items:center;gap:20px;}
21955    /* Two-column layout inside each card */
21956    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
21957    .card-left{display:flex;align-items:flex-start;min-width:0;}
21958    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
21959    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
21960    .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);}
21961    .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);}
21962    .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);}
21963    .card-text{min-width:0;}
21964    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
21965    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
21966    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
21967    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
21968    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
21969    /* Right CTA column */
21970    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
21971    .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;}
21972    /* Re-scan count badge */
21973    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
21974    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
21975    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
21976    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
21977    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
21978    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
21979    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
21980    body.dark-theme .btn-secondary{color:var(--oxide);}
21981    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
21982    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
21983    /* File input overlay — must be full-width so it aligns with other card-right buttons */
21984    .file-input-wrap{position:relative;width:100%;}
21985    .file-input-wrap .btn{width:100%;}
21986    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
21987    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21988    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21989    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21990    .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;}
21991    @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));}}
21992    /* Recent list (card 3 — full-width section below header) */
21993    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
21994    .recent-list{display:flex;flex-direction:column;gap:8px;}
21995    .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;}
21996    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
21997    .recent-item-info{flex:1;min-width:0;}
21998    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
21999    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22000    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22001    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22002    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22003    .site-footer a{color:var(--muted);}
22004    @media(max-width:680px){
22005      .card-body{grid-template-columns:1fr;}
22006      .card-right{flex-direction:row;flex-wrap:wrap;}
22007      .btn{flex:1;}
22008    }
22009    .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;}
22010    .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;}
22011    .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;}
22012  </style>
22013</head>
22014<body>
22015  <div class="background-watermarks" aria-hidden="true">
22016    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22017    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22018    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22019    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22020    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22021    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22022    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22023  </div>
22024  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22025  <div class="top-nav">
22026    <div class="top-nav-inner">
22027      <a class="brand" href="/">
22028        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22029        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22030      </a>
22031      <div class="nav-right">
22032        <a class="nav-pill" href="/">Home</a>
22033        <div class="nav-dropdown">
22034          <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>
22035          <div class="nav-dropdown-menu">
22036            <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>
22037          </div>
22038        </div>
22039        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22040        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22041        <div class="nav-dropdown">
22042          <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>
22043          <div class="nav-dropdown-menu">
22044            <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>
22045          </div>
22046        </div>
22047        <div class="server-status-wrap" id="server-status-wrap">
22048          <div class="nav-pill server-online-pill" id="server-status-pill">
22049            <span class="status-dot" id="status-dot"></span>
22050            <span id="server-status-label">Server</span>
22051            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22052          </div>
22053          <div class="server-status-tip">
22054            OxideSLOC is running — accessible on your network.
22055            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22056          </div>
22057        </div>
22058        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22059          <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>
22060        </button>
22061        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22062          <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>
22063          <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>
22064        </button>
22065      </div>
22066    </div>
22067  </div>
22068
22069  <div class="page">
22070    <div class="page-header">
22071      <h1>How would you like to scan?</h1>
22072      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22073    </div>
22074
22075    <div class="option-grid">
22076
22077      <!-- Option 1: New scan -->
22078      <div class="option-card-wrap">
22079        <div class="option-card">
22080        <div class="option-icon new-scan">
22081          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22082        </div>
22083        <div class="card-body">
22084          <div class="card-left">
22085            <div class="card-text">
22086              <div class="option-title">Start a new scan</div>
22087              <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>
22088              <ul class="feature-list">
22089                <li>Live project scope preview before you run</li>
22090                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22091                <li>HTML, PDF, and JSON output — your choice</li>
22092              </ul>
22093            </div>
22094          </div>
22095          <div class="card-right">
22096            <a class="btn btn-primary" href="/scan">
22097              Configure &amp; scan
22098              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22099            </a>
22100            <p class="card-tip">Full 4-step setup · all options</p>
22101          </div>
22102        </div>
22103        </div>
22104      </div>
22105
22106      <!-- Option 2: Load from config file -->
22107      <div class="option-card-wrap">
22108        <div class="option-card">
22109        <div class="option-icon load-config">
22110          <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>
22111        </div>
22112        <div class="card-body">
22113          <div class="card-left">
22114            <div class="card-text">
22115              <div class="option-title">Load a saved config</div>
22116              <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>
22117              <ul class="feature-list">
22118                <li>All 15 settings restored from the file</li>
22119                <li>Fully editable — change path or output dir</li>
22120                <li>Works with any scan-config.json</li>
22121              </ul>
22122            </div>
22123          </div>
22124          <div class="card-right">
22125            <div class="file-input-wrap">
22126              <button class="btn btn-secondary" id="load-config-btn" type="button">
22127                <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>
22128                Choose config file
22129              </button>
22130              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22131            </div>
22132            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22133          </div>
22134        </div>
22135        </div>
22136      </div>
22137
22138      <!-- Option 3: Re-scan recent project -->
22139      <div class="option-card-wrap">
22140        <div class="option-card" id="recent-card">
22141        <div class="card-top-row">
22142          <div class="option-icon rescan">
22143            <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>
22144          </div>
22145          <div class="card-body">
22146            <div class="card-left">
22147              <div class="card-text">
22148                <div class="option-title">Re-scan a recent project</div>
22149                <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>
22150                <ul class="feature-list">
22151                  <li>All 15+ settings restored from the saved config</li>
22152                  <li>Path and output dir are editable before running</li>
22153                  <li>Only scans with a saved config appear here</li>
22154                </ul>
22155              </div>
22156            </div>
22157            <div class="card-right">
22158              <div class="rescan-count-box">
22159                <div class="rescan-count-num" id="rescan-count-num">—</div>
22160                <div class="rescan-count-label">saved configs</div>
22161              </div>
22162              <a class="btn btn-secondary" href="/view-reports">
22163                <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>
22164                View all runs
22165              </a>
22166              <p class="card-tip">Opens run history</p>
22167            </div>
22168          </div>
22169        </div>
22170        <div class="section-divider"></div>
22171        <div class="recent-list" id="recent-list">
22172          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22173        </div>
22174        </div>
22175      </div>
22176
22177    </div>
22178  </div>
22179
22180  <footer class="site-footer">
22181    local code analysis - metrics, history and reports
22182    &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>
22183    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22184    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22185    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22186    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22187  </footer>
22188
22189  <script nonce="{{ csp_nonce }}">
22190    (function () {
22191      var storageKey = 'oxide-sloc-theme';
22192      var body = document.body;
22193      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22194      var toggle = document.getElementById('theme-toggle');
22195      if (toggle) toggle.addEventListener('click', function () {
22196        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22197        body.classList.toggle('dark-theme', next === 'dark');
22198        try { localStorage.setItem(storageKey, next); } catch(e) {}
22199      });
22200
22201      (function randomizeWatermarks() {
22202        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22203        if (!wms.length) return;
22204        var placed = [];
22205        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; }
22206        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]; }
22207        var half = Math.floor(wms.length / 2);
22208        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; });
22209      })();
22210      (function spawnCodeParticles() {
22211        var container = document.getElementById('code-particles');
22212        if (!container) return;
22213        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'];
22214        var count = 38;
22215        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); }
22216      })();
22217      // Recent scans data injected from server
22218      var recentScans = {{ recent_scans_json|safe }};
22219
22220      function configToParams(cfg) {
22221        var p = new URLSearchParams();
22222        p.set('prefilled', '1');
22223        if (cfg.path) p.set('path', cfg.path);
22224        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22225        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22226        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22227        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22228        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22229        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22230        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22231        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22232        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22233        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22234        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22235        if (cfg.report_title) p.set('report_title', cfg.report_title);
22236        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22237        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22238        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22239        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22240        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22241        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22242        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22243        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22244        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22245        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22246        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22247        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22248        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22249        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22250        return p;
22251      }
22252
22253      // Build recent scan list (capped at 3 visible entries)
22254      var list = document.getElementById('recent-list');
22255      var noNote = document.getElementById('no-recent-note');
22256      var hasAny = false;
22257      var MAX_RECENT = 3;
22258      if (Array.isArray(recentScans)) {
22259        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22260        var shown = 0;
22261        validEntries.forEach(function (entry) {
22262          if (shown >= MAX_RECENT) return;
22263          shown++;
22264          hasAny = true;
22265          var item = document.createElement('div');
22266          item.className = 'recent-item';
22267          item.title = 'Restore all settings and open wizard';
22268          item.innerHTML =
22269            '<div class="recent-item-info">' +
22270              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22271              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
22272            '</div>' +
22273            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22274          item.addEventListener('click', function () {
22275            var params = configToParams(entry.config);
22276            window.location.href = '/scan?' + params.toString();
22277          });
22278          list.appendChild(item);
22279        });
22280        if (validEntries.length > MAX_RECENT) {
22281          var moreEl = document.createElement('div');
22282          moreEl.className = 'recent-more-link';
22283          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
22284          list.appendChild(moreEl);
22285        }
22286      }
22287      if (hasAny && noNote) noNote.style.display = 'none';
22288      // Update count badge
22289      var countEl = document.getElementById('rescan-count-num');
22290      if (countEl) {
22291        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22292        countEl.textContent = total > 0 ? total : '0';
22293      }
22294
22295      // Config file loader
22296      var fileInput = document.getElementById('config-file-input');
22297      var fileName = document.getElementById('config-file-name');
22298      var loadBtn = document.getElementById('load-config-btn');
22299      // Wire the visible button to open the hidden file picker.
22300      if (loadBtn && fileInput) {
22301        loadBtn.addEventListener('click', function () { fileInput.click(); });
22302      }
22303      if (fileInput) {
22304        fileInput.addEventListener('change', function () {
22305          var file = fileInput.files && fileInput.files[0];
22306          if (!file) return;
22307          if (fileName) fileName.textContent = '\u2713 ' + file.name;
22308          var reader = new FileReader();
22309          reader.onload = function (e) {
22310            try {
22311              var cfg = JSON.parse(e.target.result);
22312              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22313              var params = configToParams(cfg);
22314              window.location.href = '/scan?' + params.toString();
22315            } catch (err) {
22316              alert('Could not parse config file: ' + err.message);
22317            }
22318          };
22319          reader.readAsText(file);
22320        });
22321      }
22322
22323      function escHtml(s) {
22324        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
22325      }
22326    })();
22327  </script>
22328  <script nonce="{{ csp_nonce }}">
22329  (function(){
22330    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'}];
22331    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);});}
22332    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22333    function init(){
22334      var btn=document.getElementById('settings-btn');if(!btn)return;
22335      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22336      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>';
22337      document.body.appendChild(m);
22338      var g=document.getElementById('scheme-grid');
22339      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);});
22340      var cl=document.getElementById('settings-close');
22341      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);
22342      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');});
22343      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22344      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22345    }
22346    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22347  }());
22348  </script>
22349  <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]';
22350  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;}
22351  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>
22352</body>
22353</html>
22354"##,
22355    ext = "html"
22356)]
22357struct ScanSetupTemplate {
22358    version: &'static str,
22359    recent_scans_json: String,
22360    csp_nonce: String,
22361}
22362
22363#[derive(Template)]
22364#[template(
22365    source = r##"
22366<!doctype html>
22367<html lang="en">
22368<head>
22369  <meta charset="utf-8">
22370  <meta name="viewport" content="width=device-width, initial-scale=1">
22371  <title>OxideSLOC | {{ report_title }} | Report</title>
22372  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22373  <style nonce="{{ csp_nonce }}">
22374    :root {
22375      --radius: 18px;
22376      --bg: #f5efe8;
22377      --surface: rgba(255,255,255,0.82);
22378      --surface-2: #fbf7f2;
22379      --surface-3: #efe6dc;
22380      --line: #e6d0bf;
22381      --line-strong: #dcb89f;
22382      --text: #43342d;
22383      --muted: #7b675b;
22384      --muted-2: #a08777;
22385      --nav: #b85d33;
22386      --nav-2: #7a371b;
22387      --accent: #6f9bff;
22388      --accent-2: #4a78ee;
22389      --oxide: #d37a4c;
22390      --oxide-2: #b35428;
22391      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22392      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22393      --success-bg: #e8f5ed;
22394      --success-text: #1a8f47;
22395      --info-bg: #eef3ff;
22396      --info-text: #4467d8;
22397    }
22398
22399    body.dark-theme {
22400      --bg: #1b1511;
22401      --surface: #261c17;
22402      --surface-2: #2d221d;
22403      --surface-3: #372922;
22404      --line: #524238;
22405      --line-strong: #6c5649;
22406      --text: #f5ece6;
22407      --muted: #c7b7aa;
22408      --muted-2: #aa9485;
22409      --nav: #b85d33;
22410      --nav-2: #7a371b;
22411      --accent: #6f9bff;
22412      --accent-2: #4a78ee;
22413      --oxide: #d37a4c;
22414      --oxide-2: #b35428;
22415      --shadow: 0 18px 42px rgba(0,0,0,0.28);
22416      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22417      --success-bg: #163927;
22418      --success-text: #8fe2a8;
22419      --info-bg: #1c2847;
22420      --info-text: #a9c1ff;
22421    }
22422
22423    * { box-sizing: border-box; }
22424    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); }
22425    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22426    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22427    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22428    .top-nav, .page { position: relative; z-index: 2; }
22429    .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); }
22430    .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; }
22431    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22432    .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)); }
22433    .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; }
22434    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22435    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22436    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22437    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22438    .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; }
22439    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22440    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22441    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22442    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22443    @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; } }
22444    .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; }
22445    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22446    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22447    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22448    .theme-toggle .icon-sun { display:none; }
22449    body.dark-theme .theme-toggle .icon-sun { display:block; }
22450    body.dark-theme .theme-toggle .icon-moon { display:none; }
22451    .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;}
22452    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22453    .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);}
22454    .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;}
22455    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22456    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22457    .settings-modal-body{padding:14px 16px 16px;}
22458    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22459    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22460    .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;}
22461    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22462    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22463    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22464    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22465    .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;}
22466    .tz-select:focus{border-color:var(--oxide);}
22467    .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; }
22468    .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;}
22469    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22470    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22471    .hero, .panel { padding: 22px; }
22472    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22473    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22474    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22475    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22476    .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; }
22477    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22478    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22479    .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; }
22480    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22481    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22482    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22483    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22484    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22485    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22486    .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); }
22487    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22488    .delta-card-val { font-size:16px; font-weight:800; }
22489    .delta-card-val.pos { color:#1e7e34; }
22490    .delta-card-val.neg { color:var(--neg); }
22491    .delta-card-val.mod { color:#b35428; }
22492    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22493    .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; }
22494    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22495    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22496    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22497    .compare-ts { font-size:13px; color:var(--muted); }
22498    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22499    .compare-arrow { color: var(--muted); }
22500    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22501    .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; }
22502    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22503    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22504    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22505    .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; }
22506    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22507    .run-mgmt-card .action-buttons { justify-content:center; }
22508    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22509    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22510    .button, .copy-button {
22511      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;
22512    }
22513    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22514    @keyframes spin { to { transform: rotate(360deg); } }
22515    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22516    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22517    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22518    .path-item strong { display: block; margin-bottom: 6px; }
22519    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22520    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22521    .path-subitem { flex: 1; }
22522    .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); }
22523    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); }
22524    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22525    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22526    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22527    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22528    th { color: var(--muted); font-weight: 700; }
22529    tr:last-child td { border-bottom: none; }
22530    #subm-tbl col:nth-child(1){width:15%;}
22531    #subm-tbl col:nth-child(2){width:31%;}
22532    #subm-tbl col:nth-child(3){width:9%;}
22533    #subm-tbl col:nth-child(4){width:9%;}
22534    #subm-tbl col:nth-child(5){width:9%;}
22535    #subm-tbl col:nth-child(6){width:9%;}
22536    #subm-tbl col:nth-child(7){width:9%;}
22537    #subm-tbl col:nth-child(8){width:9%;}
22538    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22539    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22540    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22541    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22542    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22543    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22544    .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; }
22545    .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; }
22546    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22547    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22548    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22549    .muted { color: var(--muted); }
22550    /* Run-ID chip row (mirrors HTML report) */
22551    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22552    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22553    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22554    .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; }
22555    .run-id-chip[data-copy] { cursor:pointer; }
22556    a.run-id-chip { text-decoration:none; cursor:pointer; }
22557    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22558    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22559    .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; }
22560    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22561    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22562    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22563    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22564    a.commit-link-value { color:inherit; text-decoration:none; }
22565    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22566    .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; }
22567    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22568    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22569    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22570    .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; }
22571    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22572    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22573    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22574    /* Meta chips row */
22575    .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%; }
22576    .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; }
22577    .meta-chip:last-child { border-right:none; }
22578    .meta-chip b { color:var(--text); font-weight:700; }
22579    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22580    .site-footer a{color:var(--muted);}
22581    .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; }
22582    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22583    .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; }
22584    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22585    /* Stat chips (matches HTML report) */
22586    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22587    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22588    /* Hero stat strip: uniform grid where every card is the same width and the
22589       columns line up across both rows. JS sets the column count to ceil(n/2) so
22590       the cards always occupy exactly two rows; when the count is odd the last
22591       card spans two columns to fill the trailing cell with no empty gap. */
22592    .summary-strip-hero { align-items:stretch; }
22593    .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; }
22594    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22595    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22596    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22597    .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; }
22598    .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); }
22599    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22600    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22601    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22602    .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; }
22603    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22604    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22605    .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); }
22606    .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); }
22607    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22608    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22609    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22610    /* Submodule panel */
22611    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22612    /* Metrics tables stack */
22613    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22614    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22615    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22616    .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)); }
22617    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22618    /* Metrics table */
22619    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22620    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22621    .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; }
22622    .metrics-table thead th:not(:first-child) { text-align: right; }
22623    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22624    .metrics-table tbody tr:last-child td { border-bottom: none; }
22625    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22626    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22627    .metrics-table tbody tr:hover td { background: var(--surface-2); }
22628    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22629    .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; }
22630    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22631    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22632    .mt-val-pos { color: var(--pos); font-weight: 700; }
22633    .mt-val-neg { color: var(--neg); font-weight: 700; }
22634    .mt-val-zero { color: var(--muted); }
22635    .mt-val-mod { color: var(--oxide-2); }
22636    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22637    @media (max-width: 1180px) {
22638      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22639      .nav-project-slot, .nav-status { justify-content:flex-start; }
22640      .hero-top { flex-direction: column; }
22641      .run-mgmt-strip { flex-direction: column; }
22642    }
22643    .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;}
22644    @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));}}
22645    .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;}
22646    /* ── Result-page chart controls ─────────────────────────────────────────── */
22647    .r-chart-section{margin-bottom:24px;}
22648    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22649    .section-pair > .panel{flex-shrink:0;}
22650    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22651    .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;}
22652    .r-chart-select:focus{border-color:var(--accent);}
22653    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22654    .r-chart-container svg{display:block;width:100%;height:auto;}
22655    .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;}
22656    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22657    .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;}
22658    .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);}
22659    .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;}
22660    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22661    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22662    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22663    .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;}
22664    .r-chart-modal-close:hover{opacity:.7;}
22665    body.dark-theme .r-chart-modal{background:var(--surface);}
22666    .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;}
22667    .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);}
22668    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22669    .lang-bar-row:hover{transform:translateY(-2px);}
22670    .lang-bar-row .rchit:hover{filter:none;transform:none;}
22671    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22672    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22673    .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;}
22674    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22675    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22676    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22677    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22678    #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;}
22679    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22680    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22681    .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;}
22682    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22683    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22684    .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;}
22685    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22686    .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;}
22687    .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;}
22688    body.has-report-banner .top-nav{top:27px;}
22689    body.has-report-banner{padding-bottom:27px;}
22690  </style>
22691</head>
22692<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22693  <div class="background-watermarks" aria-hidden="true">
22694    <img src="/images/logo/logo-text.png" alt="" />
22695    <img src="/images/logo/logo-text.png" alt="" />
22696    <img src="/images/logo/logo-text.png" alt="" />
22697    <img src="/images/logo/logo-text.png" alt="" />
22698    <img src="/images/logo/logo-text.png" alt="" />
22699    <img src="/images/logo/logo-text.png" alt="" />
22700    <img src="/images/logo/logo-text.png" alt="" />
22701    <img src="/images/logo/logo-text.png" alt="" />
22702    <img src="/images/logo/logo-text.png" alt="" />
22703    <img src="/images/logo/logo-text.png" alt="" />
22704    <img src="/images/logo/logo-text.png" alt="" />
22705    <img src="/images/logo/logo-text.png" alt="" />
22706    <img src="/images/logo/logo-text.png" alt="" />
22707    <img src="/images/logo/logo-text.png" alt="" />
22708  </div>
22709  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22710  {% if let Some(banner) = report_header_footer %}
22711  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22712  {% endif %}
22713  <div class="top-nav">
22714    <div class="top-nav-inner">
22715      <a class="brand" href="/">
22716        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22717        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22718      </a>
22719      <div class="nav-project-slot">
22720        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22721      </div>
22722      <div class="nav-status">
22723        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22724        <div class="nav-dropdown">
22725          <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>
22726          <div class="nav-dropdown-menu">
22727            <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>
22728          </div>
22729        </div>
22730        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22731        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22732        <div class="nav-dropdown">
22733          <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>
22734          <div class="nav-dropdown-menu">
22735            <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>
22736          </div>
22737        </div>
22738        <div class="server-status-wrap" id="server-status-wrap">
22739          <div class="nav-pill server-online-pill" id="server-status-pill">
22740            <span class="status-dot" id="status-dot"></span>
22741            <span id="server-status-label">Server</span>
22742            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22743          </div>
22744          <div class="server-status-tip">
22745            OxideSLOC is running — accessible on your network.
22746            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22747          </div>
22748        </div>
22749        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22750          <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>
22751        </button>
22752        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22753          <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>
22754          <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>
22755        </button>
22756      </div>
22757    </div>
22758  </div>
22759
22760  <div class="page">
22761    <section class="hero">
22762      <div class="hero-top">
22763        <div>
22764          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22765            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22766            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22767            <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>
22768          </div>
22769        </div>
22770        <div class="hero-quick-actions">
22771          {% if server_mode %}
22772          <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>
22773          {% else %}
22774          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22775          {% endif %}
22776          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22777          {% if !server_mode %}
22778          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22779          {% endif %}
22780          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22781          <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>
22782        </div>
22783      </div>
22784
22785      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22786      <div class="run-id-row">
22787        <span class="run-id-chip" data-copy="{{ run_id }}">
22788          <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>
22789          <span class="run-id-chip-value">{{ run_id }}</span>
22790          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22791        </span>
22792        {% match git_commit_long %}
22793          {% when Some with (long_sha) %}
22794          {% match git_commit_url %}
22795            {% when Some with (commit_url) %}
22796            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22797              <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>
22798              <span class="run-id-chip-value">{{ long_sha }}</span>
22799              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22800            </a>
22801            {% when None %}
22802            <span class="run-id-chip" data-copy="{{ long_sha }}">
22803              <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>
22804              <span class="run-id-chip-value">{{ long_sha }}</span>
22805              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22806            </span>
22807          {% endmatch %}
22808          {% when None %}
22809          <span class="run-id-chip muted-chip">
22810            <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>
22811            <span class="run-id-chip-value">Not detected</span>
22812            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22813          </span>
22814        {% endmatch %}
22815        {% match git_branch %}
22816          {% when Some with (branch) %}
22817          {% match git_branch_url %}
22818            {% when Some with (branch_url) %}
22819            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22820              <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>
22821              <span class="run-id-chip-value">{{ branch }}</span>
22822              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22823            </a>
22824            {% when None %}
22825            <span class="run-id-chip">
22826              <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>
22827              <span class="run-id-chip-value">{{ branch }}</span>
22828              <span class="chip-tooltip">Git branch active at scan time</span>
22829            </span>
22830          {% endmatch %}
22831          {% when None %}
22832          <span class="run-id-chip muted-chip">
22833            <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>
22834            <span class="run-id-chip-value">Not detected</span>
22835            <span class="chip-tooltip">No Git branch was found for this scan</span>
22836          </span>
22837        {% endmatch %}
22838        {% match git_author %}
22839          {% when Some with (author) %}
22840          <span class="run-id-chip" data-author="{{ author }}">
22841            <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>
22842            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22843            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22844          </span>
22845          {% when None %}
22846          <span class="run-id-chip muted-chip">
22847            <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>
22848            <span class="run-id-chip-value">Not detected</span>
22849            <span class="chip-tooltip">No commit author was found for this scan</span>
22850          </span>
22851        {% endmatch %}
22852      </div>
22853
22854      <!-- Scan metadata row -->
22855      <div class="meta">
22856        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22857        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22858        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22859        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22860        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22861      </div>
22862
22863      <!-- All summary stat chips in one unified strip (8 columns) -->
22864      <div class="summary-strip summary-strip-hero">
22865        <div class="stat-chip" data-raw="{{ physical_lines }}">
22866          <div class="stat-chip-label">Physical lines</div>
22867          <div class="stat-chip-val">{{ physical_lines }}</div>
22868          <div class="stat-chip-exact"></div>
22869          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
22870        </div>
22871        <div class="stat-chip" data-raw="{{ code_lines }}">
22872          <div class="stat-chip-label">Code</div>
22873          <div class="stat-chip-val">{{ code_lines }}</div>
22874          <div class="stat-chip-exact"></div>
22875          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
22876        </div>
22877        <div class="stat-chip" data-raw="{{ comment_lines }}">
22878          <div class="stat-chip-label">Comments</div>
22879          <div class="stat-chip-val">{{ comment_lines }}</div>
22880          <div class="stat-chip-exact"></div>
22881          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
22882        </div>
22883        <div class="stat-chip" data-raw="{{ blank_lines }}">
22884          <div class="stat-chip-label">Blank</div>
22885          <div class="stat-chip-val">{{ blank_lines }}</div>
22886          <div class="stat-chip-exact"></div>
22887          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
22888        </div>
22889        <div class="stat-chip" data-raw="{{ mixed_lines }}">
22890          <div class="stat-chip-label">Mixed separate</div>
22891          <div class="stat-chip-val">{{ mixed_lines }}</div>
22892          <div class="stat-chip-exact"></div>
22893          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
22894        </div>
22895        <div class="stat-chip" data-raw="{{ functions }}">
22896          <div class="stat-chip-label">Functions</div>
22897          <div class="stat-chip-val">{{ functions }}</div>
22898          <div class="stat-chip-exact"></div>
22899          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
22900        </div>
22901        <div class="stat-chip" data-raw="{{ classes }}">
22902          <div class="stat-chip-label">Classes / Types</div>
22903          <div class="stat-chip-val">{{ classes }}</div>
22904          <div class="stat-chip-exact"></div>
22905          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
22906        </div>
22907        <div class="stat-chip" data-raw="{{ variables }}">
22908          <div class="stat-chip-label">Variables</div>
22909          <div class="stat-chip-val">{{ variables }}</div>
22910          <div class="stat-chip-exact"></div>
22911          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
22912        </div>
22913        <div class="stat-chip" data-raw="{{ imports }}">
22914          <div class="stat-chip-label">Imports</div>
22915          <div class="stat-chip-val">{{ imports }}</div>
22916          <div class="stat-chip-exact"></div>
22917          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
22918        </div>
22919        <div class="stat-chip" data-raw="{{ test_count }}">
22920          <div class="stat-chip-label">Tests</div>
22921          <div class="stat-chip-val">{{ test_count }}</div>
22922          <div class="stat-chip-exact"></div>
22923          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
22924        </div>
22925        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
22926          <div class="stat-chip-label">Code density</div>
22927          <div class="stat-chip-val stat-chip-density-val">—</div>
22928          <div class="stat-chip-exact"></div>
22929          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
22930        </div>
22931        <div class="stat-chip" data-raw="{{ files_analyzed }}">
22932          <div class="stat-chip-label">Files analyzed</div>
22933          <div class="stat-chip-val">{{ files_analyzed }}</div>
22934          <div class="stat-chip-exact"></div>
22935          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
22936        </div>
22937        {% if cyclomatic_complexity > 0 %}
22938        <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 %}>
22939          <div class="stat-chip-label">Complexity score</div>
22940          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
22941          <div class="stat-chip-exact"></div>
22942          <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>
22943        </div>
22944        {% endif %}
22945        {% if let Some(ls) = lsloc %}
22946        <div class="stat-chip" data-raw="{{ ls }}">
22947          <div class="stat-chip-label">Logical SLOC</div>
22948          <div class="stat-chip-val">{{ ls }}</div>
22949          <div class="stat-chip-exact"></div>
22950          <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>
22951        </div>
22952        {% endif %}
22953        {% if uloc > 0 %}
22954        <div class="stat-chip" data-raw="{{ uloc }}">
22955          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
22956          <div class="stat-chip-val">{{ uloc }}</div>
22957          <div class="stat-chip-exact"></div>
22958          <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>
22959        </div>
22960        {% endif %}
22961        {% if uloc > 0 && dryness_pct_str != "" %}
22962        <div class="stat-chip">
22963          <div class="stat-chip-label">DRYness</div>
22964          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
22965          <div class="stat-chip-exact"></div>
22966          <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>
22967        </div>
22968        {% endif %}
22969        {% if duplicate_group_count > 0 %}
22970        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
22971          <div class="stat-chip-label">Duplicate groups</div>
22972          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
22973          <div class="stat-chip-exact"></div>
22974          <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>
22975        </div>
22976        {% endif %}
22977        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
22978             odd, so the strip always forms exactly two full rows with every column
22979             aligned and every card the same width (no oversized card, no gap). -->
22980        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
22981          <div class="stat-chip-label">Assertions</div>
22982          <div class="stat-chip-val">{{ test_assertion_count }}</div>
22983          <div class="stat-chip-exact"></div>
22984          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
22985        </div>
22986      </div>
22987
22988      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
22989      <div class="compare-banner">
22990        <div class="compare-banner-body">
22991          <div class="compare-banner-top">
22992          <div class="compare-banner-meta">
22993            <span class="compare-label">Previous scan</span>
22994            <span class="compare-ts">{{ prev_ts }}</span>
22995            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
22996            {% if let Some(prev_code) = prev_run_code_lines %}
22997            <div class="compare-banner-stats" style="margin-top:4px;">
22998              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
22999              <span class="compare-arrow">→</span>
23000              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23001              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23002              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23003            </div>
23004            {% endif %}
23005          </div>
23006          {% if delta_lines_added.is_some() %}
23007          <div class="delta-cards-inline">
23008            <div class="delta-card-inline">
23009              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23010              <div class="delta-card-lbl">lines added</div>
23011              <div class="delta-card-tip">Code lines added since the previous scan</div>
23012            </div>
23013            <div class="delta-card-inline">
23014              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23015              <div class="delta-card-lbl">lines removed</div>
23016              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23017            </div>
23018            <div class="delta-card-inline">
23019              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23020              <div class="delta-card-lbl">unmodified lines</div>
23021              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23022            </div>
23023            <div class="delta-card-inline">
23024              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23025              <div class="delta-card-lbl">files modified</div>
23026              <div class="delta-card-tip">Files with at least one line changed</div>
23027            </div>
23028            <div class="delta-card-inline">
23029              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23030              <div class="delta-card-lbl">files added</div>
23031              <div class="delta-card-tip">New files added since the previous scan</div>
23032            </div>
23033            <div class="delta-card-inline">
23034              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23035              <div class="delta-card-lbl">files removed</div>
23036              <div class="delta-card-tip">Files deleted since the previous scan</div>
23037            </div>
23038            <div class="delta-card-inline">
23039              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23040              <div class="delta-card-lbl">files unchanged</div>
23041              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23042            </div>
23043          </div>
23044          {% else %}
23045          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23046            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23047          </p>
23048          {% endif %}
23049          </div>
23050          <div class="compare-banner-actions">
23051            <div class="compare-banner-actions-left">
23052              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23053              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23054            </div>
23055            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23056          </div>
23057        </div>
23058      </div>
23059      {% endif %}{% endif %}
23060
23061      <div class="action-grid">
23062        <div class="action-card">
23063          <h3>HTML report</h3>
23064          <div class="action-buttons">
23065            {% match html_url %}
23066              {% when Some with (url) %}
23067                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23068              {% when None %}{% endmatch %}
23069            {% match html_download_url %}
23070              {% when Some with (url) %}
23071                <a class="button secondary" href="{{ url }}">Download HTML</a>
23072              {% when None %}{% endmatch %}
23073            {% match html_path %}
23074              {% when Some with (_path) %}{% when None %}{% endmatch %}
23075            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23076          </div>
23077        </div>
23078        <div class="action-card">
23079          <h3>PDF report</h3>
23080          <div class="action-buttons">
23081            {% match pdf_url %}
23082              {% when Some with (url) %}
23083                {% if pdf_generating %}
23084                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23085                    <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>
23086                    Generating PDF…
23087                  </button>
23088                {% else %}
23089                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23090                {% endif %}
23091              {% when None %}
23092                {% match html_url %}
23093                  {% when Some with (_hurl) %}
23094                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23095                    <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>
23096                  {% when None %}
23097                    <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;">
23098                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23099                    </p>
23100                {% endmatch %}
23101            {% endmatch %}
23102            {% match pdf_download_url %}
23103              {% when Some with (url) %}
23104                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23105              {% when None %}{% endmatch %}
23106            {% match pdf_url %}
23107              {% when Some with (_) %}
23108                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23109              {% when None %}{% endmatch %}
23110          </div>
23111        </div>
23112        <div class="action-card">
23113          <h3>JSON result</h3>
23114          <div class="action-buttons">
23115            {% match json_url %}
23116              {% when Some with (url) %}
23117                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23118              {% when None %}{% endmatch %}
23119            {% match json_download_url %}
23120              {% when Some with (url) %}
23121                <a class="button secondary" href="{{ url }}">Download JSON</a>
23122              {% when None %}{% endmatch %}
23123            {% match json_path %}
23124              {% when Some with (_path) %}
23125                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23126              {% when None %}
23127                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23128              {% endmatch %}
23129          </div>
23130        </div>
23131        <div class="action-card">
23132          <h3>Scan config</h3>
23133          <div class="action-buttons">
23134            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23135            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23136            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23137          </div>
23138        </div>
23139        {% if confluence_configured %}
23140        <div class="action-card" id="confluenceCard">
23141          <h3>Confluence</h3>
23142          <div class="action-buttons">
23143            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23144            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23145          </div>
23146          <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>
23147        </div>
23148        {% endif %}
23149      </div>
23150      {% if confluence_configured %}
23151      <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;">
23152        <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);">
23153          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23154          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23155          <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;">
23156          <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>
23157          <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;">
23158          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23159          <div style="display:flex;gap:10px;justify-content:flex-end;">
23160            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23161            <button class="button" id="confSubmitBtn" type="button">Post</button>
23162          </div>
23163        </div>
23164      </div>
23165      {% endif %}
23166      <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;">
23167        <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);">
23168          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23169          <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>
23170          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23171          <div style="display:flex;gap:18px;justify-content:flex-end;">
23172            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23173            <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>
23174          </div>
23175        </div>
23176      </div>
23177      {% if !submodule_rows.is_empty() %}
23178      <div class="submodule-panel">
23179        <div class="toolbar-row">
23180          <div>
23181            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23182            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23183          </div>
23184          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23185        </div>
23186        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23187        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23188          <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>
23189          <thead>
23190            <tr>
23191              <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>
23192              <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>
23193              <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>
23194              <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>
23195              <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>
23196              <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>
23197              <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>
23198              <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>
23199            </tr>
23200          </thead>
23201          <tbody>
23202            {% for row in submodule_rows %}
23203            <tr>
23204              <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>
23205              <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>
23206              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23207              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23208              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23209              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23210              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23211              <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>
23212            </tr>
23213            {% endfor %}
23214          </tbody>
23215        </table>
23216        </div>
23217      </div>
23218      {% endif %}
23219
23220      <div class="metrics-tables-stack">
23221
23222        <div class="metrics-table-wrap">
23223          <div class="metrics-table-title">Files</div>
23224          <table class="metrics-table">
23225            <thead>
23226              <tr>
23227                <th>Metric</th>
23228                <th>This Run</th>
23229                <th>Previous</th>
23230                <th>Change</th>
23231              </tr>
23232            </thead>
23233            <tbody>
23234              <tr>
23235                <td>Files analyzed</td>
23236                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23237                <td>{{ prev_fa_str|commas }}</td>
23238                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23239              </tr>
23240              <tr>
23241                <td>Files skipped</td>
23242                <td>{{ files_skipped|commas }}</td>
23243                <td>{{ prev_fs_str|commas }}</td>
23244                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23245              </tr>
23246              <tr>
23247                <td>Files modified</td>
23248                <td class="mt-val-na">—</td>
23249                <td class="mt-val-na">—</td>
23250                <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>
23251              </tr>
23252              <tr>
23253                <td>Files unchanged</td>
23254                <td class="mt-val-na">—</td>
23255                <td class="mt-val-na">—</td>
23256                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23257              </tr>
23258            </tbody>
23259          </table>
23260        </div>
23261
23262        <div class="metrics-table-wrap">
23263          <div class="metrics-table-title">Line Counts</div>
23264          <table class="metrics-table">
23265            <thead>
23266              <tr>
23267                <th>Metric</th>
23268                <th>This Run</th>
23269                <th>Previous</th>
23270                <th>Change</th>
23271              </tr>
23272            </thead>
23273            <tbody>
23274              <tr>
23275                <td>Physical lines</td>
23276                <td class="mt-val-large">{{ physical_lines|commas }}</td>
23277                <td>{{ prev_pl_str|commas }}</td>
23278                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23279              </tr>
23280              <tr>
23281                <td>Code lines</td>
23282                <td class="mt-val-large">{{ code_lines|commas }}</td>
23283                <td>{{ prev_cl_str|commas }}</td>
23284                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23285              </tr>
23286              <tr>
23287                <td>Comment lines</td>
23288                <td>{{ comment_lines|commas }}</td>
23289                <td>{{ prev_cml_str|commas }}</td>
23290                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23291              </tr>
23292              <tr>
23293                <td>Blank lines</td>
23294                <td>{{ blank_lines|commas }}</td>
23295                <td>{{ prev_bl_str|commas }}</td>
23296                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23297              </tr>
23298              <tr>
23299                <td>Mixed (separate)</td>
23300                <td>{{ mixed_lines|commas }}</td>
23301                <td class="mt-val-na">—</td>
23302                <td class="mt-val-na">—</td>
23303              </tr>
23304            </tbody>
23305          </table>
23306        </div>
23307
23308        <div class="metrics-tables-lower">
23309          <div class="metrics-table-wrap">
23310            <div class="metrics-table-title">Code Structure</div>
23311            <table class="metrics-table">
23312              <thead>
23313                <tr>
23314                  <th>Metric</th>
23315                  <th>This Run</th>
23316                </tr>
23317              </thead>
23318              <tbody>
23319                <tr>
23320                  <td>Functions</td>
23321                  <td>{{ functions|commas }}</td>
23322                </tr>
23323                <tr>
23324                  <td>Classes / Types</td>
23325                  <td>{{ classes|commas }}</td>
23326                </tr>
23327                <tr>
23328                  <td>Variables</td>
23329                  <td>{{ variables|commas }}</td>
23330                </tr>
23331                <tr>
23332                  <td>Imports</td>
23333                  <td>{{ imports|commas }}</td>
23334                </tr>
23335              </tbody>
23336            </table>
23337          </div>
23338
23339          <div class="metrics-table-wrap">
23340            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23341            <table class="metrics-table">
23342              <thead>
23343                <tr>
23344                  <th>Metric</th>
23345                  <th>Change</th>
23346                </tr>
23347              </thead>
23348              <tbody>
23349                <tr>
23350                  <td>Lines added</td>
23351                  <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>
23352                </tr>
23353                <tr>
23354                  <td>Lines removed</td>
23355                  <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>
23356                </tr>
23357                <tr>
23358                  <td>Lines modified (net)</td>
23359                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23360                </tr>
23361                <tr>
23362                  <td>Lines unmodified</td>
23363                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23364                </tr>
23365              </tbody>
23366            </table>
23367          </div>
23368        </div>
23369
23370      </div>
23371
23372      <div class="path-list">
23373        <div class="path-item">
23374          <div class="path-item-label">Project path</div>
23375          {% 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 %}
23376        </div>
23377        <div class="path-item">
23378          <div class="path-item-label">Git branch</div>
23379          {% if let Some(branch) = git_branch %}
23380          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23381          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23382          {% else %}
23383          <code style="color:var(--muted)">—</code>
23384          {% endif %}
23385        </div>
23386        <div class="path-item">
23387          <div class="path-item-label">Output folder</div>
23388          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23389        </div>
23390        <div class="path-item">
23391          <div class="path-item-label">Run ID</div>
23392          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23393            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23394            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23395          </div>
23396        </div>
23397      </div>
23398    </section>
23399
23400    {% if has_cocomo %}
23401    <div class="cocomo-box" style="margin-top:24px;">
23402      <div class="cocomo-box-head">
23403        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
23404        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23405          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23406          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23407        </span>
23408      </div>
23409      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23410        <div class="stat-chip">
23411          <div class="stat-chip-label">Person-months</div>
23412          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23413          <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>
23414        </div>
23415        <div class="stat-chip">
23416          <div class="stat-chip-label">Schedule (months)</div>
23417          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23418          <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>
23419        </div>
23420        <div class="stat-chip">
23421          <div class="stat-chip-label">Avg. Team Size</div>
23422          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23423          <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>
23424        </div>
23425        <div class="stat-chip">
23426          <div class="stat-chip-label">Input KSLOC</div>
23427          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23428          <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>
23429        </div>
23430      </div>
23431      <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>
23432    </div>
23433    {% endif %}
23434
23435    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23436    <div class="cocomo-box" style="margin-top:24px;">
23437      <div class="cocomo-box-head">
23438        <span class="cocomo-box-title">Tests &amp; Coverage</span>
23439        {% if has_coverage_data %}
23440        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23441          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23442        </span>
23443        {% endif %}
23444      </div>
23445      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23446        <div class="stat-chip">
23447          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23448          <div class="stat-chip-label">Test Functions</div>
23449          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23450        </div>
23451        <div class="stat-chip">
23452          {% if has_coverage_data %}
23453          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23454          {% else %}
23455          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23456          {% endif %}
23457          <div class="stat-chip-label">Line Coverage</div>
23458          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23459        </div>
23460        <div class="stat-chip">
23461          {% if !cov_fn_pct.is_empty() %}
23462          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23463          {% else %}
23464          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23465          {% endif %}
23466          <div class="stat-chip-label">Fn Coverage</div>
23467          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23468        </div>
23469        <div class="stat-chip">
23470          {% if !cov_branch_pct.is_empty() %}
23471          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23472          {% else %}
23473          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23474          {% endif %}
23475          <div class="stat-chip-label">Branch Coverage</div>
23476          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23477        </div>
23478      </div>
23479      {% if has_coverage_data %}
23480      <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>
23481      {% else %}
23482      <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>
23483      {% endif %}
23484    </div>
23485
23486    <div class="section-pair">
23487    <section class="panel">
23488        <div class="toolbar-row">
23489          <div>
23490            <h2>Language Breakdown</h2>
23491            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23492          </div>
23493          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23494        </div>
23495        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23496    </section>
23497
23498    <section class="panel r-chart-section">
23499      <div class="toolbar-row" style="margin-bottom:16px;">
23500        <div>
23501          <h2>Visualizations</h2>
23502          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23503        </div>
23504      </div>
23505
23506      <div class="r-viz-grid">
23507        <div class="r-viz-card">
23508          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23509            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23510            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23511          </div>
23512          <div class="r-chart-tab-bar">
23513            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23514            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23515          </div>
23516          <div class="r-chart-container" id="r-composition-chart"></div>
23517        </div>
23518        <div class="r-viz-card">
23519          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23520            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23521            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23522          </div>
23523          <div class="r-chart-container" id="r-scatter-chart"></div>
23524        </div>
23525        {% if has_semantic_data %}
23526        <div class="r-viz-card">
23527          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23528            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23529            <select class="r-chart-select" id="r-semantic-metric">
23530              <option value="functions">Functions</option>
23531              <option value="classes">Classes</option>
23532              <option value="variables">Variables</option>
23533              <option value="imports">Imports</option>
23534              <option value="tests">Tests</option>
23535            </select>
23536            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23537          </div>
23538          <div class="r-chart-container" id="r-semantic-chart"></div>
23539        </div>
23540        {% endif %}
23541        <div class="r-viz-card">
23542          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23543            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23544            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23545          </div>
23546          <div class="r-chart-container" id="r-density-chart"></div>
23547        </div>
23548        <div class="r-viz-card">
23549          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23550            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23551            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23552          </div>
23553          <div class="r-chart-container" id="r-avglines-chart"></div>
23554        </div>
23555        <div class="r-viz-card">
23556          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23557            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23558            <select class="r-chart-select" id="r-sub-metric">
23559              <option value="code">Code Lines</option>
23560              <option value="comment">Comments</option>
23561              <option value="blank">Blank Lines</option>
23562              <option value="physical">Physical Lines</option>
23563              <option value="files">Files</option>
23564            </select>
23565            <select class="r-chart-select" id="r-sub-sort">
23566              <option value="desc">Value ↓</option>
23567              <option value="asc">Value ↑</option>
23568              <option value="name">Name A→Z</option>
23569            </select>
23570            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23571          </div>
23572          <div class="r-chart-container" id="r-submodule-chart"></div>
23573        </div>
23574      </div>
23575
23576    </section>
23577    </div>
23578
23579  </div>
23580
23581  <div id="r-tt" aria-hidden="true"></div>
23582
23583  <script nonce="{{ csp_nonce }}">
23584    (function () {
23585      var body = document.body;
23586      var themeToggle = document.getElementById('theme-toggle');
23587      var storageKey = 'oxide-sloc-theme';
23588
23589      function applyTheme(theme) {
23590        body.classList.toggle('dark-theme', theme === 'dark');
23591      }
23592
23593      function loadSavedTheme() {
23594        try {
23595          var saved = localStorage.getItem(storageKey);
23596          if (saved === 'dark' || saved === 'light') {
23597            applyTheme(saved);
23598          }
23599        } catch (e) {}
23600      }
23601
23602      if (themeToggle) {
23603        themeToggle.addEventListener('click', function () {
23604          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23605          applyTheme(nextTheme);
23606          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23607        });
23608      }
23609
23610      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23611        button.addEventListener('click', function () {
23612          var value = button.getAttribute('data-copy-value') || '';
23613          if (!value) return;
23614          var originalText = button.textContent;
23615          function flashSuccess() {
23616            button.textContent = 'Copied!';
23617            setTimeout(function () { button.textContent = originalText; }, 1800);
23618          }
23619          function flashFail() {
23620            button.textContent = 'Copy failed';
23621            setTimeout(function () { button.textContent = originalText; }, 2000);
23622          }
23623          if (navigator.clipboard && navigator.clipboard.writeText) {
23624            navigator.clipboard.writeText(value).then(flashSuccess, function () {
23625              fallbackCopy(value, flashSuccess, flashFail);
23626            });
23627          } else {
23628            fallbackCopy(value, flashSuccess, flashFail);
23629          }
23630        });
23631      });
23632      function fallbackCopy(text, onSuccess, onFail) {
23633        try {
23634          var ta = document.createElement('textarea');
23635          ta.value = text;
23636          ta.style.position = 'fixed';
23637          ta.style.top = '-9999px';
23638          ta.style.left = '-9999px';
23639          document.body.appendChild(ta);
23640          ta.focus();
23641          ta.select();
23642          var ok = document.execCommand('copy');
23643          document.body.removeChild(ta);
23644          if (ok) { onSuccess(); } else { onFail(); }
23645        } catch (e) { onFail(); }
23646      }
23647
23648      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23649        btn.addEventListener('click', function () {
23650          var folder = btn.getAttribute('data-folder') || '';
23651          if (!folder) return;
23652          var orig = btn.textContent;
23653          fetch('/open-path?path=' + encodeURIComponent(folder))
23654            .then(function (r) { return r.json(); })
23655            .then(function (d) {
23656              if (d && d.server_mode_disabled) {
23657                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23658              } else if (d && d.ok) {
23659                btn.textContent = 'Opened!';
23660                setTimeout(function () { btn.textContent = orig; }, 1800);
23661              }
23662            })
23663            .catch(function () {
23664              btn.textContent = 'Failed';
23665              setTimeout(function () { btn.textContent = orig; }, 2000);
23666            });
23667        });
23668      });
23669
23670      loadSavedTheme();
23671
23672      // ── Compact number formatting for stat chips ──────────────────────────
23673      (function(){
23674        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();}
23675        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23676          var raw=parseInt(chip.getAttribute('data-raw'),10);
23677          if(isNaN(raw))return;
23678          var valEl=chip.querySelector('.stat-chip-val');
23679          if(valEl)valEl.textContent=fmt(raw);
23680          var exactEl=chip.querySelector('.stat-chip-exact');
23681          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23682        });
23683        // Code density chip
23684        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23685          var code=parseInt(chip.getAttribute('data-code'),10);
23686          var phys=parseInt(chip.getAttribute('data-physical'),10);
23687          if(isNaN(code)||isNaN(phys)||phys===0)return;
23688          var pct=(code/phys*100).toFixed(1)+'%';
23689          var valEl=chip.querySelector('.stat-chip-val');
23690          if(valEl)valEl.textContent=pct;
23691        });
23692        // Populate author handle from data-author attribute
23693        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23694          var author=chip.getAttribute('data-author');
23695          var el=chip.querySelector('.author-handle');
23696          if(el)el.textContent='/'+author.replace(/\s+/g,'');
23697        });
23698        // Click-to-copy on run-id-chip elements
23699        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23700          chip.addEventListener('click',function(){
23701            var val=chip.getAttribute('data-copy');
23702            if(!val)return;
23703            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23704            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);}
23705            chip.classList.add('chip-copied-flash');
23706            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23707          });
23708        });
23709        // Format delta card values with data-raw using comma-separated full numbers
23710        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23711          var raw=parseInt(card.getAttribute('data-raw'),10);
23712          if(isNaN(raw))return;
23713          var valEl=card.querySelector('.delta-card-val');
23714          if(valEl)valEl.textContent=raw.toLocaleString();
23715        });
23716        // Format code-before / code-now numbers in the compare banner stats line
23717        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23718          var raw=parseInt(el.getAttribute('data-raw'),10);
23719          if(!isNaN(raw))el.textContent=raw.toLocaleString();
23720        });
23721      })();
23722
23723      // ── Shared tooltip for all result-page charts ─────────────────────────
23724      var rTT=(function(){
23725        var el=document.getElementById('r-tt');
23726        if(!el)return{s:function(){},h:function(){},m:function(){}};
23727        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23728        function hide(){el.style.display='none';}
23729        function move(e){
23730          var x=e.clientX+16,y=e.clientY-12;
23731          var r=el.getBoundingClientRect();
23732          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23733          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23734          el.style.left=x+'px';el.style.top=y+'px';
23735        }
23736        return{s:show,h:hide,m:move};
23737      })();
23738      window.rTT=rTT;
23739
23740      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23741      (function(){
23742        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23743        document.addEventListener('mouseover',function(e){
23744          var t=e.target;
23745          while(t&&t.getAttribute){
23746            var l=t.getAttribute('data-ttl');
23747            if(l!==null){
23748              var v=t.getAttribute('data-ttv')||'';
23749              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23750              return;
23751            }
23752            t=t.parentNode;
23753          }
23754        });
23755        document.addEventListener('mouseout',function(e){
23756          var t=e.target;
23757          while(t&&t.getAttribute){
23758            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23759            t=t.parentNode;
23760          }
23761        });
23762        document.addEventListener('mousemove',function(e){
23763          var el=document.getElementById('r-tt');
23764          if(el&&el.style.display!=='none')rTT.m(e);
23765        });
23766        window.addEventListener('blur',function(){rTT.h();});
23767        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23768      })();
23769
23770      // ── Language overview charts ───────────────────────────────────────────
23771      (function(){
23772        var D={{ lang_chart_json|safe }};
23773        if(!D||!D.length)return;
23774        var el=document.getElementById('result-lang-charts');
23775        if(!el)return;
23776        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23777        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23778        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23779        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();}
23780        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23781        function px(n){return Math.round(n);}
23782        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+'"';}
23783        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23784        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23785        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23786        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;}
23787        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23788
23789        // Donut chart — height matches the stacked-bar chart so both panels align
23790        var rHb_d=28;
23791        var DH=Math.max(220,D.length*rHb_d+32);
23792        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23793        var legX=208,DW=395;
23794        var legCount=D.length;
23795        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23796        var legYStart=Math.round((DH-legCount*legSpacing)/2);
23797        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">';
23798        if(D.length===1){
23799          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23800          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+'"/>';
23801        } else {
23802          var smalls=[];
23803          var ang=-Math.PI/2;
23804          D.forEach(function(d,i){
23805            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23806            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23807            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23808            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23809            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23810            var pct=Math.round(d.code/tot*100);
23811            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"/>';
23812            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]});}
23813            ang+=sw;
23814          });
23815          // Small slices (<5%) get outside labels positioned near each slice's own
23816          // angular position (a slice on the left gets its label/leader on the left),
23817          // then nudged apart horizontally so text never overlaps. Leader lines point
23818          // from each slice to its label. Horizontal text keeps long names legible;
23819          // the whole SVG scales up in Full View so these stay readable there too.
23820          if(smalls.length){
23821            smalls.sort(function(a,b){return a.mAng-b.mAng;});
23822            var sPad=6,sRowY=11;
23823            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)));});
23824            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;}
23825            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23826            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23827            smalls.forEach(function(sm){
23828              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23829              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;"/>';
23830              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>';
23831            });
23832          }
23833        }
23834        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23835        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23836        D.forEach(function(d,i){
23837          var ly=legYStart+i*legSpacing;
23838          var pctL=Math.round(d.code/tot*100);
23839          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23840          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23841          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23842          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23843          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23844          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23845          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>';
23846          ds+='</g>';
23847        });
23848        ds+='</svg>';
23849
23850        // Horizontal stacked-bar chart — fills container width
23851        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23852        var LW=108,BW=260,svgW=LW+BW+68;
23853        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23854        var barBH=Math.min(32,Math.round(barRhb*0.7));
23855        var SH=DH;
23856        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23857        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">';
23858        D.forEach(function(d,i){
23859          var y=barTopPad+i*barRhb,x=LW;
23860          var phys=d.physical||d.code+d.comments+d.blanks;
23861          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23862          var lmid=y+barBH/2+4;
23863          // Combined breakdown shown when hovering the row, the language name, or the
23864          // total at the bar end (\n becomes a line break in the tooltip).
23865          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23866          bs+='<g class="lang-bar-row">';
23867          // Hit area ends just past the total label so empty space to the right of the
23868          // bar does not trigger the tooltip — only the name, bar and total are hot.
23869          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
23870          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
23871          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>';
23872          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;}
23873          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;}
23874          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>';}
23875          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>';
23876          bs+='</g>';
23877        });
23878        var ly=SH-14;
23879        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
23880        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
23881        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
23882        var totAll=totC+totCm+totBl||1;
23883        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
23884        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
23885        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
23886        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
23887        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
23888        bs+='<g data-kind="code" style="cursor:pointer;">'
23889          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
23890          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
23891          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
23892          +'</g>';
23893        bs+='<g data-kind="comment" style="cursor:pointer;">'
23894          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
23895          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
23896          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
23897          +'</g>';
23898        bs+='<g data-kind="blank" style="cursor:pointer;">'
23899          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
23900          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
23901          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
23902          +'</g>';
23903        bs+='</svg>';
23904        el.innerHTML='<div class="r-lang-overview">'+
23905          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
23906          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
23907        '</div>';
23908        function wireDonutLegend(svg){
23909          if(!svg)return;
23910          var paths=svg.querySelectorAll('path[data-lang]');
23911          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';}}}
23912          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
23913          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();});
23914          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();});
23915          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
23916        }
23917        function wireMixLegend(svg){
23918          if(!svg)return;
23919          var legGs=svg.querySelectorAll('g[data-kind]');
23920          var allRects=svg.querySelectorAll('rect[data-kind]');
23921          if(!legGs.length)return;
23922          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';}}
23923          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='';}}
23924          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]);}
23925        }
23926        wireDonutLegend(el.querySelector('svg'));
23927        wireMixLegend(el.querySelectorAll('svg')[1]);
23928
23929        // ── Language breakdown Full View expand ─────────────────────────────────
23930        var langOvBtn=document.getElementById('result-lang-overview-expand');
23931        if(langOvBtn){langOvBtn.addEventListener('click',function(){
23932          var src=document.getElementById('result-lang-charts');
23933          if(!src)return;
23934          var overlay=document.createElement('div');
23935          overlay.className='r-chart-modal-overlay';
23936          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>';
23937          document.body.appendChild(overlay);
23938          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
23939          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
23940          var wrap=document.getElementById('result-lang-overview-modal-wrap');
23941          if(wrap){
23942            wrap.innerHTML=src.innerHTML;
23943            var svgs=wrap.querySelectorAll('svg');
23944            for(var i=0;i<svgs.length;i++){
23945              svgs[i].removeAttribute('width');
23946              svgs[i].removeAttribute('height');
23947              svgs[i].style.cssText='display:block;width:100%;height:auto;';
23948            }
23949            var ov=wrap.querySelector('.r-lang-overview');
23950            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
23951            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
23952            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
23953            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
23954            wireDonutLegend(wrap.querySelector('svg'));
23955            wireMixLegend(wrap.querySelectorAll('svg')[1]);
23956            requestAnimationFrame(function(){
23957              var ss=wrap.querySelectorAll('svg');
23958              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%;';}}
23959            });
23960          }
23961        });}
23962      })();
23963
23964      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
23965      (function(){
23966        var LANG_D={{ lang_chart_json|safe }};
23967        var SCAT_D={{ scatter_chart_json|safe }};
23968        var SEM_D={{ semantic_chart_json|safe }};
23969        var SUB_D={{ submodule_chart_json|safe }};
23970        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
23971        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23972        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();}
23973        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23974        function px(n){return Math.round(n);}
23975        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+'"';}
23976        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
23977        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
23978        // rather than disappear; the SVG scales up in Full View).
23979        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;}
23980
23981        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
23982        function renderCompositionInEl(el,mode,shOvr){
23983          if(!el||!LANG_D||!LANG_D.length)return;
23984          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23985          var LW=110,SH=shOvr||300;
23986          var svgW=Math.max(320,el.offsetWidth||480);
23987          var BW=Math.max(120,svgW-LW-80);
23988          var legendH=24,topPad=4;
23989          var n=LANG_D.length||1;
23990          var rowTotal=Math.floor((SH-legendH-topPad)/n);
23991          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
23992          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">';
23993          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
23994          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
23995          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
23996          var totAll2=totC2+totCm2+totBl2||1;
23997          if(mode==='pct'){
23998            LANG_D.forEach(function(d,i){
23999              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24000              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24001              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24002              var lmid=y+Math.floor(bH/2)+4;
24003              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24004              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>';
24005              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;}
24006              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;}
24007              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>';}
24008              var pct=Math.round((d.code||0)/tot2*100);
24009              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>';
24010            });
24011          } else {
24012            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24013            LANG_D.forEach(function(d,i){
24014              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24015              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24016              var lmid=y+Math.floor(bH/2)+4;
24017              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));
24018              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>';
24019              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;}
24020              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;}
24021              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>';}
24022              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>';
24023            });
24024          }
24025          var ly=SH-legendH+4;
24026          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24027          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24028          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24029          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24030          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24031          s+='<g data-kind="code" style="cursor:pointer;">'
24032            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24033            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24034            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24035            +'</g>';
24036          s+='<g data-kind="comment" style="cursor:pointer;">'
24037            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24038            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24039            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24040            +'</g>';
24041          s+='<g data-kind="blank" style="cursor:pointer;">'
24042            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24043            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24044            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24045            +'</g>';
24046          s+='</svg>';
24047          el.innerHTML=s;
24048          wireMixLegendEl(el);
24049        }
24050        function wireMixLegendEl(container){
24051          var svg=container&&container.querySelector('svg');
24052          if(!svg)return;
24053          var legGs=svg.querySelectorAll('g[data-kind]');
24054          var allRects=svg.querySelectorAll('rect[data-kind]');
24055          if(!legGs.length)return;
24056          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';}}
24057          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='';}}
24058          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]);}
24059        }
24060        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24061        renderComposition('abs');
24062        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24063          btn.addEventListener('click',function(){
24064            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24065            btn.classList.add('active');
24066            renderComposition(btn.getAttribute('data-rcomp'));
24067          });
24068        });
24069
24070        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24071        function wireScatterLegend(container){
24072          var svg=container&&container.querySelector('svg');
24073          if(!svg)return;
24074          var legGs=svg.querySelectorAll('g[data-lang]');
24075          var circs=svg.querySelectorAll('circle[data-lang]');
24076          if(!legGs.length)return;
24077          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';}}
24078            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24079          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='';}}
24080          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]);}
24081        }
24082        function renderScatterInEl(el,hOvr){
24083          if(!el||!SCAT_D||!SCAT_D.length)return;
24084          var n=SCAT_D.length;
24085          var H=hOvr||300,PL=52,PB=36,PT=44;
24086          var W=Math.max(320,el.offsetWidth||480);
24087          var cH=H-PT-PB;
24088          // Legend: max 2 columns, fills vertical space. The compact card shows the
24089          // top languages by code lines plus a "+N more" row linking to Full View;
24090          // Full View (hOvr set) shows every language across up to 2 tall columns.
24091          var compact=!hOvr;
24092          var availH=Math.max(120,H-24);
24093          var rowsFit=Math.max(2,Math.floor(availH/18));
24094          var legTrunc=compact&&(n>2*rowsFit);
24095          var legShown=legTrunc?(2*rowsFit-1):n;
24096          var legTotal=legTrunc?(2*rowsFit):n;
24097          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24098          var legPerCol=Math.ceil(legTotal/legCols);
24099          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24100          var legColW=hOvr?144:130;
24101          var LG=26;
24102          var legW=legCols*legColW;
24103          var cW=W-PL-LG-legW;
24104          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24105          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24106          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24107          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24108          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24109          var logMaxF=Math.log1p(maxF);
24110          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">';
24111          // Y grid lines (linear)
24112          [0,0.25,0.5,0.75,1].forEach(function(t){
24113            var y=PT+cH*(1-t);
24114            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24115            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>';
24116          });
24117          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24118          [0,0.25,0.5,0.75,1].forEach(function(t){
24119            var x=PL+cW*t;
24120            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24121            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24122            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>';
24123          });
24124          // Full View (hOvr set) has the vertical room to show the per-bubble value
24125          // line; the compact card shows only the language label to avoid the
24126          // overlapping-label clutter seen when bubbles cluster together.
24127          var showVal=!!hOvr;
24128          SCAT_D.forEach(function(d,i){
24129            // X uses log1p so outlier languages (many files) don't push others to the far left
24130            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24131            var cy2=PT+cH-d.code/maxC*cH;
24132            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24133            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"/>';
24134            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24135            if(showVal){
24136              var ty2=Math.max(24,px(cy2)-px(r)-3);
24137              var ty1=Math.max(12,ty2-14);
24138              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>';
24139              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>';
24140            }else{
24141              var ly2=Math.max(12,px(cy2)-px(r)-3);
24142              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>';
24143            }
24144          });
24145          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>';
24146          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>';
24147          // Legend (right side — top languages, max 2 columns, fills height)
24148          var legX=PL+cW+LG;
24149          var legBlockH=legPerCol*legRowH;
24150          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24151          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24152          for(var lk=0;lk<legShown;lk++){
24153            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24154            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24155            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;">';
24156            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24157            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24158            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>';
24159            s+='</g>';
24160          }
24161          if(legTrunc){
24162            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24163            s+='<g data-more="1" style="cursor:pointer;">';
24164            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24165            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24166            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>';
24167            s+='</g>';
24168          }
24169          s+='</svg>';
24170          el.innerHTML=s;
24171          wireScatterLegend(el);
24172          var moreEl=el.querySelector('g[data-more]');
24173          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24174        }
24175        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24176
24177        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24178        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24179        // the old vertical column layout on wide containers.
24180        function renderSemanticInEl(el,key,sh){
24181          if(!el||!SEM_D||!SEM_D.length)return;
24182          var n2=SEM_D.length||1;
24183          var LW=112,SH=sh||Math.max(180,n2*28+26);
24184          var svgW=Math.max(320,el.offsetWidth||480);
24185          var BW=Math.max(120,svgW-LW-80);
24186          var topPad=4,botPad=14;
24187          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24188          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24189          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24190          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">';
24191          SEM_D.forEach(function(d,i){
24192            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24193            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>';
24194            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"/>';
24195            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>';
24196          });
24197          s+='</svg>';
24198          el.innerHTML=s;
24199        }
24200        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24201        var semSel=document.getElementById('r-semantic-metric');
24202        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24203        var semExpand=document.getElementById('r-semantic-expand');
24204        if(semExpand){
24205          semExpand.addEventListener('click',function(){
24206            var key=semSel?semSel.value:'functions';
24207            var n=SEM_D.length||1;
24208            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24209            var modalH=Math.min(Math.max(360,n*38+60),maxH);
24210            var overlay=document.createElement('div');
24211            overlay.className='r-chart-modal-overlay';
24212            var optHtml=
24213              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24214              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24215              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24216              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24217              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24218            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>';
24219            document.body.appendChild(overlay);
24220            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24221            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24222            var modalEl=document.getElementById('r-sem-modal-chart');
24223            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24224            var modalSel=document.getElementById('r-sem-modal-metric');
24225            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24226          });
24227        }
24228
24229        // ── Expand buttons: re-render charts at large size inside modal ──────────
24230        (function(){
24231          function makeExpandModal(title,mH,subtitle,ctrlHtml){
24232            var overlay=document.createElement('div');
24233            overlay.className='r-chart-modal-overlay';
24234            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24235            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24236            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>';
24237            document.body.appendChild(overlay);
24238            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24239            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24240            return overlay.querySelector('.r-expand-modal-chart');
24241          }
24242          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24243          var compExpandBtn=document.getElementById('r-composition-expand');
24244          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24245            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24246            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24247            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24248              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24249            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24250            if(wrap){
24251              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24252              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24253                btn.addEventListener('click',function(){
24254                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24255                  btn.classList.add('active');
24256                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24257                });
24258              });
24259            }
24260          });}
24261          var scatExpandBtn=document.getElementById('r-scatter-expand');
24262          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24263            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24264            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24265          });}
24266          var densExpandBtn=document.getElementById('r-density-expand');
24267          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24268            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24269            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24270            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24271          });}
24272          var avgExpandBtn=document.getElementById('r-avglines-expand');
24273          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24274            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24275            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24276            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24277          });}
24278          var subExpandBtn=document.getElementById('r-submodule-expand');
24279          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24280            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24281            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24282            var metCtrl=
24283              '<select class="r-chart-select" id="r-sub-modal-metric">'
24284              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24285              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24286              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24287              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24288              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24289              +'</select>';
24290            var sortCtrl=
24291              '<select class="r-chart-select" id="r-sub-modal-sort">'
24292              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24293              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24294              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24295              +'</select>';
24296            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24297            if(wrap){
24298              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24299              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24300              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24301              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24302              if(mSub)mSub.addEventListener('change',reRenderSub);
24303              if(mSort)mSort.addEventListener('change',reRenderSub);
24304            }
24305          });}
24306        })();
24307
24308        // ── Comment Density: comments / (code + comments) per language ───────────
24309        function renderDensityInEl(el,shOvr){
24310          if(!el||!LANG_D||!LANG_D.length)return;
24311          var n=LANG_D.length||1;
24312          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24313          var svgW=Math.max(320,el.offsetWidth||480);
24314          var BW=Math.max(120,svgW-LW-80);
24315          var topPad=4,botPad=26;
24316          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24317          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24318          var densities=LANG_D.map(function(d){
24319            var sig=(d.code||0)+(d.comments||0);
24320            return sig>0?(d.comments||0)/sig:0;
24321          });
24322          var maxDen=Math.max.apply(null,densities)||1;
24323          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">';
24324          LANG_D.forEach(function(d,i){
24325            var den=densities[i],bw=den/maxDen*BW;
24326            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24327            var pct=Math.round(den*100);
24328            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>';
24329            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"/>';
24330            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24331            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>';
24332          });
24333          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>';
24334          s+='</svg>';
24335          el.innerHTML=s;
24336        }
24337        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24338        renderDensity();
24339
24340        // ── Avg Lines per File: code / files per language ─────────────────────
24341        function renderAvgLinesInEl(el,shOvr){
24342          if(!el||!LANG_D||!LANG_D.length)return;
24343          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24344          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24345          var n=data.length||1;
24346          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24347          var svgW=Math.max(320,el.offsetWidth||480);
24348          var BW=Math.max(120,svgW-LW-80);
24349          var topPad=4,botPad=26;
24350          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24351          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24352          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24353          var maxAvg=Math.max.apply(null,avgs)||1;
24354          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">';
24355          data.forEach(function(d,i){
24356            var avg=avgs[i],bw=avg/maxAvg*BW;
24357            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24358            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>';
24359            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"/>';
24360            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24361            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>';
24362          });
24363          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>';
24364          s+='</svg>';
24365          el.innerHTML=s;
24366        }
24367        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24368        renderAvgLines();
24369
24370        // ── Repository Overview: overall row + per-submodule rows ────────────
24371        function renderSubmoduleInEl(el,key,sort,shOvr){
24372          if(!el)return;
24373          var overall={
24374            name:'Overall',
24375            code:{{ code_lines }},
24376            comment:{{ comment_lines }},
24377            blank:{{ blank_lines }},
24378            physical:{{ physical_lines }},
24379            files:{{ files_analyzed }},
24380            isOverall:true
24381          };
24382          var subs=SUB_D.slice();
24383          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24384          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24385          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24386          var data=[overall].concat(subs);
24387          var sepH=subs.length>0?14:0;
24388          var naturalH=data.length*32+sepH+16;
24389          var SH=shOvr||Math.max(100,naturalH);
24390          var svgW=Math.max(320,el.offsetWidth||480);
24391          var LW=116,BW=Math.max(200,svgW-LW-54);
24392          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24393          var OVERALL_COL='#6b7280';
24394          var topPad=4,botPad=8;
24395          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24396          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24397          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">';
24398          var yOff=topPad;
24399          data.forEach(function(d,i){
24400            var v=d[key]||0,bw=v/maxV*BW;
24401            var y=yOff+Math.floor((rowSlot-bH)/2);
24402            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24403            var label=d.name||d.path||'?';
24404            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>';
24405            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24406            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24407            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>';
24408            yOff+=rowSlot;
24409            if(d.isOverall&&subs.length>0){
24410              yOff+=sepH;
24411            }
24412          });
24413          s+='</svg>';
24414          el.innerHTML=s;
24415        }
24416        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24417        var subSel=document.getElementById('r-sub-metric');
24418        var sortSel=document.getElementById('r-sub-sort');
24419        renderSubmodule('code','desc');
24420        if(subSel){
24421          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24422          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24423        }
24424
24425        // Equalise heights within each chart row: if one chart in a grid row is taller
24426        // than its neighbour, re-render the shorter one at the taller height so bars fill
24427        // the available vertical space instead of leaving a gap.
24428        function syncRowHeights(){
24429          var avgEl=document.getElementById('r-avglines-chart');
24430          var subEl=document.getElementById('r-submodule-chart');
24431          if(avgEl&&subEl){
24432            var avgSvg=avgEl.querySelector('svg');
24433            var subSvg=subEl.querySelector('svg');
24434            if(avgSvg&&subSvg){
24435              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24436              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24437              var key=subSel?subSel.value||'code':'code';
24438              var sort=sortSel?sortSel.value:'desc';
24439              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24440              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24441            }
24442          }
24443          var semEl=document.getElementById('r-semantic-chart');
24444          var denEl=document.getElementById('r-density-chart');
24445          if(semEl&&denEl){
24446            var semSvg=semEl.querySelector('svg');
24447            var denSvg=denEl.querySelector('svg');
24448            if(semSvg&&denSvg){
24449              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24450              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24451              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24452              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24453            }
24454          }
24455        }
24456        syncRowHeights();
24457
24458        // Re-render all SVG charts when the window is resized so bars fill the card.
24459        var _rResizeTimer;
24460        window.addEventListener('resize',function(){
24461          clearTimeout(_rResizeTimer);
24462          _rResizeTimer=setTimeout(function(){
24463            var rcompBtn=document.querySelector('[data-rcomp].active');
24464            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24465            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24466            if(semSel)renderSemantic(semSel.value||'functions');
24467            renderDensity();
24468            renderAvgLines();
24469            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24470            syncRowHeights();
24471          },120);
24472        });
24473      })();
24474
24475      (function randomizeWatermarks() {
24476        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24477        if (!wms.length) return;
24478        var placed = [];
24479        function tooClose(top, left) {
24480          for (var i = 0; i < placed.length; i++) {
24481            var dt = Math.abs(placed[i][0] - top);
24482            var dl = Math.abs(placed[i][1] - left);
24483            if (dt < 20 && dl < 18) return true;
24484          }
24485          return false;
24486        }
24487        function pick(leftBand) {
24488          for (var attempt = 0; attempt < 50; attempt++) {
24489            var top = Math.random() * 85 + 5;
24490            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24491            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24492          }
24493          var top = Math.random() * 85 + 5;
24494          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24495          placed.push([top, left]);
24496          return [top, left];
24497        }
24498        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24499        var half = Math.floor(wms.length / 2);
24500        wms.forEach(function (img, i) {
24501          var pos = pick(i < half);
24502          var size = Math.floor(Math.random() * 100 + 160);
24503          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24504          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24505          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;
24506        });
24507      })();
24508
24509      (function spawnCodeParticles() {
24510        var container = document.getElementById('code-particles');
24511        if (!container) return;
24512        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'];
24513        for (var i = 0; i < 38; i++) {
24514          (function(idx) {
24515            var el = document.createElement('span');
24516            el.className = 'code-particle';
24517            el.textContent = snippets[idx % snippets.length];
24518            var left = Math.random() * 94 + 2;
24519            var top = Math.random() * 88 + 6;
24520            var dur = (Math.random() * 10 + 9).toFixed(1);
24521            var delay = (Math.random() * 18).toFixed(1);
24522            var rot = (Math.random() * 26 - 13).toFixed(1);
24523            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24524            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';
24525            container.appendChild(el);
24526          })(i);
24527        }
24528      })();
24529
24530      {% if pdf_generating %}
24531      // Poll for PDF readiness and swap the disabled button to a live link once done.
24532      (function() {
24533        var openBtn = document.getElementById('pdf-open-btn');
24534        var dlBtn = document.getElementById('pdf-download-btn');
24535        function checkPdf() {
24536          fetch('/api/runs/{{ run_id }}/pdf-status')
24537            .then(function(r) { return r.json(); })
24538            .then(function(d) {
24539              if (d.ready) {
24540                if (openBtn) {
24541                  var a = document.createElement('a');
24542                  a.className = 'button';
24543                  a.id = 'pdf-open-btn';
24544                  a.href = '/runs/pdf/{{ run_id }}';
24545                  a.target = '_blank';
24546                  a.rel = 'noopener';
24547                  a.textContent = 'Open PDF';
24548                  openBtn.replaceWith(a);
24549                }
24550                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24551              } else {
24552                setTimeout(checkPdf, 3000);
24553              }
24554            })
24555            .catch(function() { setTimeout(checkPdf, 5000); });
24556        }
24557        setTimeout(checkPdf, 3000);
24558      })();
24559      {% endif %}
24560
24561    })();
24562  </script>
24563  <script nonce="{{ csp_nonce }}">
24564  (function(){
24565    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'}];
24566    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);});}
24567    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24568    function init(){
24569      var btn=document.getElementById('settings-btn');if(!btn)return;
24570      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24571      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>';
24572      document.body.appendChild(m);
24573      var g=document.getElementById('scheme-grid');
24574      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);});
24575      var cl=document.getElementById('settings-close');
24576      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);
24577      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');});
24578      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24579      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24580    }
24581    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24582  }());
24583  </script>
24584  <footer class="site-footer">
24585    local code analysis - metrics, history and reports
24586    &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>
24587    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24588    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24589    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24590    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
24591  </footer>
24592  {% if confluence_configured %}
24593  <script nonce="{{ csp_nonce }}">
24594  (function() {
24595    var postBtn = document.getElementById('postConfluenceBtn');
24596    var copyBtn = document.getElementById('copyWikiBtn');
24597    var modal   = document.getElementById('confluenceModal');
24598    if (!postBtn || !modal) return;
24599
24600    postBtn.addEventListener('click', function() {
24601      document.getElementById('confStatus').style.display = 'none';
24602      modal.style.display = 'flex';
24603    });
24604    document.getElementById('confCancelBtn').addEventListener('click', function() {
24605      modal.style.display = 'none';
24606    });
24607    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24608
24609    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24610      var btn = this;
24611      btn.disabled = true;
24612      var status = document.getElementById('confStatus');
24613      status.style.display = 'block';
24614      status.style.background = '#dbeafe';
24615      status.style.color = '#1e40af';
24616      status.textContent = 'Posting to Confluence\u2026';
24617      var resp = await fetch('/api/confluence/post', {
24618        method: 'POST',
24619        headers: { 'Content-Type': 'application/json' },
24620        body: JSON.stringify({
24621          run_id: '{{ run_id }}',
24622          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24623          report_url: document.getElementById('confReportUrl').value.trim() || null
24624        })
24625      });
24626      var data = await resp.json();
24627      if (data.ok) {
24628        status.style.background = '#dcfce7'; status.style.color = '#166534';
24629        status.textContent = 'Posted! Page ID: ' + data.page_id;
24630      } else {
24631        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24632        status.textContent = 'Error: ' + (data.error || 'Unknown error');
24633      }
24634      btn.disabled = false;
24635    });
24636
24637    if (copyBtn) {
24638      copyBtn.addEventListener('click', async function() {
24639        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24640        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24641        var text = await resp.text();
24642        try {
24643          await navigator.clipboard.writeText(text);
24644          var orig = copyBtn.textContent;
24645          copyBtn.textContent = 'Copied!';
24646          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24647        } catch(e) {
24648          alert('Clipboard write failed \u2014 check browser permissions.');
24649        }
24650      });
24651    }
24652  })();
24653  </script>
24654  {% endif %}
24655  <script nonce="{{ csp_nonce }}">
24656  (function() {
24657    var deleteBtn = document.getElementById('delete-run-btn');
24658    var modal     = document.getElementById('delete-run-modal');
24659    var cancelBtn = document.getElementById('delete-run-cancel');
24660    var confirmBtn= document.getElementById('delete-run-confirm');
24661    if (!deleteBtn || !modal) return;
24662    deleteBtn.addEventListener('click', function() {
24663      document.getElementById('delete-run-status').style.display = 'none';
24664      modal.style.display = 'flex';
24665    });
24666    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24667    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24668    confirmBtn.addEventListener('click', async function() {
24669      confirmBtn.disabled = true;
24670      cancelBtn.disabled = true;
24671      var status = document.getElementById('delete-run-status');
24672      status.style.display = 'block';
24673      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24674      status.textContent = 'Deleting\u2026';
24675      try {
24676        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24677        if (resp.status === 204 || resp.ok) {
24678          status.style.background = '#dcfce7'; status.style.color = '#166534';
24679          status.textContent = 'Deleted. Redirecting\u2026';
24680          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24681        } else {
24682          var d = await resp.json().catch(function(){return {};});
24683          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24684          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24685          confirmBtn.disabled = false;
24686          cancelBtn.disabled = false;
24687        }
24688      } catch (e) {
24689        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24690        status.textContent = 'Network error: ' + String(e);
24691        confirmBtn.disabled = false;
24692        cancelBtn.disabled = false;
24693      }
24694    });
24695  })();
24696  </script>
24697  <script nonce="{{ csp_nonce }}">(function(){
24698    var bundleBtn = document.getElementById('download-bundle-btn');
24699    if (bundleBtn) {
24700      bundleBtn.addEventListener('click', function() {
24701        bundleBtn.disabled = true;
24702        var orig = bundleBtn.textContent;
24703        bundleBtn.textContent = 'Preparing\u2026';
24704        fetch('/api/runs/{{ run_id }}/bundle')
24705          .then(function(r) {
24706            if (!r.ok) throw new Error('HTTP ' + r.status);
24707            return r.blob();
24708          })
24709          .then(function(blob) {
24710            var url = URL.createObjectURL(blob);
24711            var a = document.createElement('a');
24712            a.href = url;
24713            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24714            document.body.appendChild(a);
24715            a.click();
24716            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24717            bundleBtn.disabled = false;
24718            bundleBtn.textContent = orig;
24719          })
24720          .catch(function(e) {
24721            bundleBtn.disabled = false;
24722            bundleBtn.textContent = orig;
24723            alert('Bundle download failed: ' + String(e));
24724          });
24725      });
24726    }
24727  })();</script>
24728  <script nonce="{{ csp_nonce }}">(function(){
24729    var dot=document.getElementById('status-dot');
24730    var pingEl=document.getElementById('server-ping-ms');
24731    var tipEl=document.getElementById('server-tip-ping');
24732    var fm=document.getElementById('footer-mode');
24733    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)';}}
24734    function doPing(){
24735      var t0=performance.now();
24736      fetch('/healthz',{cache:'no-store'})
24737        .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);})
24738        .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)';}});
24739    }
24740    doPing();
24741    setInterval(doPing,5000);
24742    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');}
24743  })();</script>
24744  <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>
24745  {% if let Some(banner) = report_header_footer %}
24746  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24747  {% endif %}
24748</body>
24749</html>
24750"##,
24751    ext = "html"
24752)]
24753// Template structs need many bool fields to pass Askama rendering flags.
24754#[allow(clippy::struct_excessive_bools)]
24755struct ResultTemplate {
24756    version: &'static str,
24757    report_title: String,
24758    project_path: String,
24759    output_dir: String,
24760    run_id: String,
24761    files_analyzed: u64,
24762    files_skipped: u64,
24763    physical_lines: u64,
24764    code_lines: u64,
24765    comment_lines: u64,
24766    blank_lines: u64,
24767    mixed_lines: u64,
24768    functions: u64,
24769    classes: u64,
24770    variables: u64,
24771    imports: u64,
24772    html_url: Option<String>,
24773    pdf_url: Option<String>,
24774    json_url: Option<String>,
24775    html_download_url: Option<String>,
24776    pdf_download_url: Option<String>,
24777    json_download_url: Option<String>,
24778    html_path: Option<String>,
24779    json_path: Option<String>,
24780    prev_run_id: Option<String>,
24781    prev_run_timestamp: Option<String>,
24782    prev_run_code_lines: Option<u64>,
24783    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
24784    prev_fa_str: String,
24785    prev_fs_str: String,
24786    prev_pl_str: String,
24787    prev_cl_str: String,
24788    prev_cml_str: String,
24789    prev_bl_str: String,
24790    // Signed change column for main metrics
24791    delta_fa_str: String,
24792    delta_fa_class: String,
24793    delta_fs_str: String,
24794    delta_fs_class: String,
24795    delta_pl_str: String,
24796    delta_pl_class: String,
24797    delta_cl_str: String,
24798    delta_cl_class: String,
24799    delta_cml_str: String,
24800    delta_cml_class: String,
24801    delta_bl_str: String,
24802    delta_bl_class: String,
24803    // delta vs previous scan
24804    delta_lines_added: Option<i64>,
24805    delta_lines_removed: Option<i64>,
24806    delta_lines_net_str: String,
24807    delta_lines_net_class: String,
24808    delta_files_added: Option<usize>,
24809    delta_files_removed: Option<usize>,
24810    delta_files_modified: Option<usize>,
24811    delta_files_unchanged: Option<usize>,
24812    delta_unmodified_lines: Option<u64>,
24813    // git context
24814    git_branch: Option<String>,
24815    git_branch_url: Option<String>,
24816    git_commit: Option<String>,
24817    git_commit_long: Option<String>,
24818    git_author: Option<String>,
24819    git_commit_url: Option<String>,
24820    // scan metadata for hero section
24821    scan_performed_by: String,
24822    scan_time_display: String,
24823    os_display: String,
24824    test_count: u64,
24825    // reserve "pad" card, revealed by JS only when the visible card count is odd
24826    test_assertion_count: u64,
24827    // history
24828    prev_scan_count: usize,
24829    current_scan_number: usize,
24830    // submodule breakdown (empty when not requested)
24831    submodule_rows: Vec<SubmoduleRow>,
24832    scan_config_url: String,
24833    lang_chart_json: String,
24834    // Askama reads these via proc-macro expansion; clippy can't trace through it.
24835    #[allow(dead_code)]
24836    scatter_chart_json: String,
24837    #[allow(dead_code)]
24838    semantic_chart_json: String,
24839    #[allow(dead_code)]
24840    submodule_chart_json: String,
24841    #[allow(dead_code)]
24842    has_submodule_data: bool,
24843    #[allow(dead_code)]
24844    has_semantic_data: bool,
24845    pdf_generating: bool,
24846    csp_nonce: String,
24847    /// Whether Confluence integration is configured — shows Post button when true.
24848    confluence_configured: bool,
24849    server_mode: bool,
24850    /// Header/footer identification banner, mirrored from the HTML/PDF report.
24851    report_header_footer: Option<String>,
24852    run_id_short: String,
24853    /// True when rendering a static offline file (index.html); hides server-only actions.
24854    #[allow(dead_code)]
24855    is_offline: bool,
24856    /// Total cyclomatic complexity score across all analyzed files.
24857    cyclomatic_complexity: u64,
24858    /// Logical SLOC (statement count) when available; None for unsupported languages.
24859    lsloc: Option<u64>,
24860    /// Unique Lines of Code across all analyzed files.
24861    uloc: u64,
24862    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
24863    dryness_pct_str: String,
24864    /// Number of duplicate file groups detected.
24865    duplicate_group_count: usize,
24866    /// Whether a COCOMO estimate is available to display.
24867    has_cocomo: bool,
24868    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
24869    cocomo_effort_str: String,
24870    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
24871    cocomo_duration_str: String,
24872    /// Pre-formatted average team size, e.g. "2.32".
24873    cocomo_staff_str: String,
24874    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
24875    cocomo_ksloc_str: String,
24876    /// COCOMO mode label shown in the card (e.g. "Organic").
24877    cocomo_mode_label: String,
24878    /// Tooltip text explaining the selected COCOMO mode.
24879    cocomo_mode_tooltip: String,
24880    /// Per-file complexity alert threshold. 0 = off (no highlighting).
24881    complexity_alert: u32,
24882    /// Whether any file has coverage data attached.
24883    has_coverage_data: bool,
24884    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
24885    cov_line_pct: String,
24886    /// Overall function coverage percentage string — empty if no data.
24887    cov_fn_pct: String,
24888    /// Overall branch coverage percentage string — empty if no branch data.
24889    cov_branch_pct: String,
24890    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
24891    cov_lines_summary: String,
24892}
24893
24894#[derive(Template)]
24895#[template(
24896    source = r##"
24897<!doctype html>
24898<html lang="en">
24899<head>
24900  <meta charset="utf-8">
24901  <meta name="viewport" content="width=device-width, initial-scale=1">
24902  <title>OxideSLOC | Analyzing…</title>
24903  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24904  <style nonce="{{ csp_nonce }}">
24905    :root {
24906      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24907      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24908      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
24909      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24910    }
24911    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24912    *{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;}
24913    .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);}
24914    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24915    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24916    .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));}
24917    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24918    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24919    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
24920    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24921    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24922    @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; } }
24923    .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;}
24924    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24925    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24926    .page-body{padding:32px 24px 36px;}
24927    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
24928    .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;}
24929    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
24930    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
24931    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
24932    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
24933    .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;}
24934    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
24935    .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;}
24936    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
24937    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
24938    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
24939    .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;}
24940    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
24941    .hidden{display:none!important;}
24942    .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;}
24943    .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;}
24944    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
24945    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
24946    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
24947    .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);}
24948    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
24949    .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;}
24950    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
24951    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24952    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24953    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
24954    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24955    .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;}
24956    @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));}}
24957    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24958    .site-footer a{color:var(--muted);}
24959    .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;}
24960    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
24961    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
24962    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
24963  </style>
24964</head>
24965<body>
24966  <div class="background-watermarks" aria-hidden="true">
24967    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24968    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24969    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24970    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24971    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24972    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24973  </div>
24974  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24975  <nav class="top-nav">
24976    <div class="top-nav-inner">
24977      <a href="/" class="brand">
24978        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
24979        <div class="brand-copy">
24980          <h1 class="brand-title">OxideSLOC</h1>
24981          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
24982        </div>
24983      </a>
24984      <div class="nav-right">
24985        <a class="nav-pill" href="/">Home</a>
24986        <div class="nav-dropdown">
24987          <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>
24988          <div class="nav-dropdown-menu">
24989            <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>
24990          </div>
24991        </div>
24992        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24993        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24994        <div class="nav-dropdown">
24995          <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>
24996          <div class="nav-dropdown-menu">
24997            <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>
24998          </div>
24999        </div>
25000        <div class="server-status-wrap" id="server-status-wrap">
25001          <div class="nav-pill server-online-pill" id="server-status-pill">
25002            <span class="status-dot" id="status-dot"></span>
25003            <span id="server-status-label">Server</span>
25004            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25005          </div>
25006          <div class="server-status-tip">
25007            OxideSLOC is running — accessible on your network.
25008            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25009          </div>
25010        </div>
25011        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25012          <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>
25013        </button>
25014        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25015          <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>
25016          <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>
25017        </button>
25018      </div>
25019    </div>
25020  </nav>
25021  <div class="page-body">
25022    <div class="wait-panel">
25023      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25024      <h2 class="wait-title">Analyzing your project…</h2>
25025      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25026      <div class="path-block">{{ project_path }}</div>
25027      <div class="metrics-row">
25028        <div class="metric-card">
25029          <div class="metric-label">Elapsed</div>
25030          <div class="metric-value" id="elapsed">0s</div>
25031        </div>
25032        <div class="metric-card">
25033          <div class="metric-label">Phase</div>
25034          <div class="metric-value" id="phase">Starting</div>
25035        </div>
25036        <div class="metric-card hidden" id="files-card">
25037          <div class="metric-label">Files</div>
25038          <div class="metric-value" id="files-progress">0</div>
25039        </div>
25040      </div>
25041      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25042      <div class="warn-slow hidden" id="warn-slow">
25043        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.
25044      </div>
25045      <div class="err-panel hidden" id="err-panel">
25046        <strong>Analysis failed</strong>
25047        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25048      </div>
25049      <div class="actions hidden" id="actions">
25050        <a href="/scan" class="btn-primary">Try Again</a>
25051        <a href="/view-reports" class="btn-outline">View Reports</a>
25052      </div>
25053    </div>
25054  </div>
25055  <script nonce="{{ csp_nonce }}">
25056    (function() {
25057      var WAIT_ID = {{ wait_id_json|safe }};
25058      var startTime = Date.now();
25059      var pollInterval = 1500;
25060      var retries = 0;
25061      var maxRetries = 5;
25062      var warnShown = false;
25063
25064      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();}
25065
25066      function elapsed() {
25067        return Math.floor((Date.now() - startTime) / 1000);
25068      }
25069
25070      function updateElapsed() {
25071        var s = elapsed();
25072        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25073      }
25074
25075      function setPhase(txt) {
25076        document.getElementById('phase').textContent = txt;
25077      }
25078
25079      var elapsedTimer = setInterval(updateElapsed, 1000);
25080
25081      function poll() {
25082        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25083          .then(function(r) {
25084            if (!r.ok) throw new Error('HTTP ' + r.status);
25085            return r.json();
25086          })
25087          .then(function(data) {
25088            retries = 0;
25089            if (data.state === 'complete') {
25090              clearInterval(elapsedTimer);
25091              setPhase('Done');
25092              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25093            } else if (data.state === 'failed') {
25094              clearInterval(elapsedTimer);
25095              setPhase('Failed');
25096              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25097              document.getElementById('err-panel').classList.remove('hidden');
25098              document.getElementById('actions').classList.remove('hidden');
25099            } else {
25100              // still running
25101              var s = elapsed();
25102              if (s > 90 && !warnShown) {
25103                warnShown = true;
25104                document.getElementById('warn-slow').classList.remove('hidden');
25105              }
25106              setPhase(data.phase || 'Running');
25107              var fd = data.files_done || 0, ft = data.files_total || 0;
25108              if (ft > 0) {
25109                var card = document.getElementById('files-card');
25110                if (card) card.classList.remove('hidden');
25111                var fp = document.getElementById('files-progress');
25112                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25113              }
25114              setTimeout(poll, pollInterval);
25115            }
25116          })
25117          .catch(function(err) {
25118            retries++;
25119            if (retries >= maxRetries) {
25120              clearInterval(elapsedTimer);
25121              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25122              document.getElementById('err-panel').classList.remove('hidden');
25123              document.getElementById('actions').classList.remove('hidden');
25124            } else {
25125              // exponential back-off capped at 8s
25126              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25127            }
25128          });
25129      }
25130
25131      setTimeout(poll, pollInterval);
25132
25133      // If the browser restores this page from bfcache (Back after viewing results),
25134      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25135      window.addEventListener("pageshow", function(e) {
25136        if (e.persisted) { setTimeout(poll, 200); }
25137      });
25138    })();
25139  </script>
25140  <footer class="site-footer">
25141    local code analysis - metrics, history and reports
25142    &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>
25143    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25144    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25145    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25146    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25147  </footer>
25148  <script nonce="{{ csp_nonce }}">
25149    (function(){
25150      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25151      if(s==="dark")b.classList.add("dark-theme");
25152      var tt=document.getElementById("theme-toggle");
25153      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25154    })();
25155    (function spawnCodeParticles(){
25156      var c=document.getElementById('code-particles');if(!c)return;
25157      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'];
25158      for(var i=0;i<32;i++){(function(idx){
25159        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25160        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25161        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25162        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25163        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25164        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25165        c.appendChild(el);
25166      })(i);}
25167    })();
25168    (function randomizeWatermarks(){
25169      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25170      var placed=[];
25171      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;}
25172      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];}
25173      var half=Math.floor(wms.length/2);
25174      wms.forEach(function(img,i){
25175        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25176        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25177        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25178        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25179        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25180        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25181      });
25182    })();
25183  </script>
25184  <script nonce="{{ csp_nonce }}">
25185  (function(){
25186    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'}];
25187    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);});}
25188    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25189    function init(){
25190      var btn=document.getElementById('settings-btn');if(!btn)return;
25191      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25192      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>';
25193      document.body.appendChild(m);
25194      var g=document.getElementById('scheme-grid');
25195      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);});
25196      var cl=document.getElementById('settings-close');
25197      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);
25198      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');});
25199      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25200      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25201    }
25202    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25203  }());
25204  </script>
25205  <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]';
25206  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;}
25207  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>
25208</body>
25209</html>
25210"##,
25211    ext = "html"
25212)]
25213struct ScanWaitTemplate {
25214    version: &'static str,
25215    wait_id_json: String,
25216    project_path: String,
25217    csp_nonce: String,
25218}
25219
25220#[derive(Template)]
25221#[template(
25222    source = r##"
25223<!doctype html>
25224<html lang="en">
25225<head>
25226  <meta charset="utf-8">
25227  <meta name="viewport" content="width=device-width, initial-scale=1">
25228  <title>OxideSLOC | Error</title>
25229  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25230  <style nonce="{{ csp_nonce }}">
25231    :root {
25232      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25233      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25234      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25235      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25236    }
25237    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25238    *{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;}
25239    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25240    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25241    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25242    .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);}
25243    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25244    .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));}
25245    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25246    .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;}
25247    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25248    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25249    @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; } }
25250    .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;}
25251    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25252    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25253    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25254    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25255    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25256    .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;}
25257    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25258    .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);}
25259    .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;}
25260    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25261    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25262    .settings-modal-body{padding:14px 16px 16px;}
25263    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25264    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25265    .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;}
25266    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25267    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25268    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25269    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25270    .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;}
25271    .tz-select:focus{border-color:var(--oxide);}
25272    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25273    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25274    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25275    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25276    .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;}
25277    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25278    .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);}
25279    .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;}
25280    .btn-secondary:hover{background:var(--line);}
25281    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25282    .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;}
25283    .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;}
25284    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25285    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25286    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25287    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25288    .bug-report-panel.open{display:flex;}
25289    .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;}
25290    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25291    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25292    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25293    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25294    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25295    .br-network-badge.online .br-net-dot{background:#2a6846;}
25296    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25297    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25298    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25299    .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;}
25300    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25301    .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;}
25302    .btn-sm:hover{background:var(--line);}
25303    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25304    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25305    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25306    .bug-report-hint a:hover{text-decoration:underline;}
25307    .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;}
25308    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25309    .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;}
25310    .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;}
25311    .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;}
25312    @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));}}
25313    .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;}
25314  </style>
25315</head>
25316<body>
25317  <div class="background-watermarks" aria-hidden="true">
25318    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25319    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25320    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25321    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25322    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25323    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25324  </div>
25325  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25326  <div class="top-nav">
25327    <div class="top-nav-inner">
25328      <a class="brand" href="/">
25329        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25330        <div class="brand-copy">
25331          <div class="brand-title">OxideSLOC</div>
25332          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25333        </div>
25334      </a>
25335      <div class="nav-right">
25336        <a class="nav-pill" href="/">Home</a>
25337        <div class="nav-dropdown">
25338          <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>
25339          <div class="nav-dropdown-menu">
25340            <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>
25341          </div>
25342        </div>
25343        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25344        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25345        <div class="nav-dropdown">
25346          <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>
25347          <div class="nav-dropdown-menu">
25348            <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>
25349          </div>
25350        </div>
25351        <div class="server-status-wrap" id="server-status-wrap">
25352          <div class="nav-pill server-online-pill" id="server-status-pill">
25353            <span class="status-dot" id="status-dot"></span>
25354            <span id="server-status-label">Server</span>
25355            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25356          </div>
25357          <div class="server-status-tip">
25358            OxideSLOC is running — accessible on your network.
25359            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25360          </div>
25361        </div>
25362        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25363          <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>
25364        </button>
25365        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25366          <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>
25367          <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>
25368        </button>
25369      </div>
25370    </div>
25371  </div>
25372
25373  <div class="page">
25374    <div class="panel">
25375      <h1>Error</h1>
25376      <div class="error-box" id="error-msg-text">{{ message }}</div>
25377      <div id="br-meta" hidden
25378        data-version="{{ version }}"
25379        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25380        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25381      <div class="actions">
25382        <a class="btn-primary" href="/scan">Back to setup</a>
25383        {% if let Some(report_url) = last_report_url %}
25384        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25385        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25386        {% else %}
25387        <a class="btn-secondary" href="/view-reports">View Reports</a>
25388        {% endif %}
25389      </div>
25390      <div class="bug-report-section" id="bug-report-section">
25391        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25392          <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>
25393          Generate Bug Report
25394          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25395        </button>
25396        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25397          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
25398          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
25399          <div class="bug-report-btns">
25400            <button type="button" class="btn-sm" id="bug-report-copy">
25401              <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>
25402              Copy to clipboard
25403            </button>
25404            <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;">
25405              <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>
25406              Open GitHub Issue
25407            </a>
25408            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25409              <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>
25410              Save as file
25411            </button>
25412          </div>
25413          <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>
25414          <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>
25415        </div>
25416      </div>
25417    </div>
25418  </div>
25419  <footer class="site-footer">
25420    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25421    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25422    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25423    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25424    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25425  </footer>
25426  <script nonce="{{ csp_nonce }}">(function(){
25427    var meta=document.getElementById('br-meta');
25428    var pre=document.getElementById('bug-report-pre');
25429    var copyBtn=document.getElementById('bug-report-copy');
25430    var trigger=document.getElementById('bug-report-trigger');
25431    var panel=document.getElementById('bug-report-panel');
25432    var networkBadge=document.getElementById('br-network-badge');
25433    var networkLabel=document.getElementById('br-network-label');
25434    var ghLink=document.getElementById('bug-report-github-link');
25435    var saveBtn=document.getElementById('bug-report-save');
25436    var hintOnline=document.getElementById('br-hint-online');
25437    var hintOffline=document.getElementById('br-hint-offline');
25438    if(!meta||!pre)return;
25439    var ver=meta.getAttribute('data-version')||'';
25440    var runId=meta.getAttribute('data-run-id')||'';
25441    var code=meta.getAttribute('data-error-code')||'';
25442    var msgEl=document.getElementById('error-msg-text');
25443    var msg=msgEl?msgEl.textContent.trim():'';
25444    function getBrowser(){
25445      var ua=navigator.userAgent;
25446      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25447      if(!m)return 'Unknown browser';
25448      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25449      return n+' '+m[2];
25450    }
25451    var lines=['oxide-sloc Bug Report','==============================',''];
25452    lines.push('App version:  v'+ver);
25453    if(code)lines.push('HTTP status:  '+code);
25454    if(runId)lines.push('Run ID:       '+runId);
25455    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
25456    lines.push('Timestamp:    '+new Date().toISOString());
25457    lines.push('Browser:      '+getBrowser());
25458    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
25459    lines.push('');
25460    lines.push('Error message:');
25461    lines.push(msg);
25462    lines.push('');
25463    lines.push('Steps to reproduce:');
25464    lines.push('  1. ');
25465    lines.push('');
25466    lines.push('Expected behavior:');
25467    lines.push('  ');
25468    pre.textContent=lines.join('\n');
25469    function applyNetwork(online){
25470      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25471      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25472      if(ghLink){
25473        if(online){
25474          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25475          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25476        }
25477        ghLink.style.display=online?'inline-flex':'none';
25478      }
25479      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25480      if(hintOnline)hintOnline.style.display=online?'block':'none';
25481      if(hintOffline)hintOffline.style.display=online?'none':'block';
25482    }
25483    applyNetwork(navigator.onLine);
25484    var probed=false;
25485    function probeNetwork(){
25486      if(probed)return;probed=true;
25487      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25488      var probeIdx=0;
25489      function tryNext(){
25490        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25491        var u=probeUrls[probeIdx++];
25492        var c2=new AbortController();
25493        var t2=setTimeout(function(){c2.abort();},4000);
25494        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25495          .then(function(){clearTimeout(t2);applyNetwork(true);})
25496          .catch(function(){clearTimeout(t2);tryNext();});
25497      }
25498      tryNext();
25499    }
25500    if(trigger&&panel){
25501      trigger.addEventListener('click',function(){
25502        var open=panel.classList.toggle('open');
25503        trigger.classList.toggle('open',open);
25504        trigger.setAttribute('aria-expanded',open?'true':'false');
25505        if(open)probeNetwork();
25506      });
25507    }
25508    if(copyBtn){
25509      copyBtn.addEventListener('click',function(){
25510        var txt=pre.textContent;
25511        if(navigator.clipboard&&navigator.clipboard.writeText){
25512          navigator.clipboard.writeText(txt).then(function(){
25513            copyBtn.textContent='\u2713 Copied!';
25514            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);
25515          });
25516        }else{
25517          var ta=document.createElement('textarea');
25518          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25519          document.body.appendChild(ta);ta.select();
25520          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25521          document.body.removeChild(ta);
25522        }
25523      });
25524    }
25525    if(saveBtn){
25526      saveBtn.addEventListener('click',function(){
25527        var txt=pre.textContent;
25528        var blob=new Blob([txt],{type:'text/plain'});
25529        var url=URL.createObjectURL(blob);
25530        var a=document.createElement('a');
25531        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25532        document.body.appendChild(a);a.click();
25533        document.body.removeChild(a);URL.revokeObjectURL(url);
25534      });
25535    }
25536  })();</script>
25537  <script nonce="{{ csp_nonce }}">
25538    (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");});})();
25539    (function spawnCodeParticles() {
25540      var container = document.getElementById('code-particles');
25541      if (!container) return;
25542      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'];
25543      for (var i = 0; i < 38; i++) {
25544        (function(idx) {
25545          var el = document.createElement('span');
25546          el.className = 'code-particle';
25547          el.textContent = snippets[idx % snippets.length];
25548          var left = Math.random() * 94 + 2;
25549          var top = Math.random() * 88 + 6;
25550          var dur = (Math.random() * 10 + 9).toFixed(1);
25551          var delay = (Math.random() * 18).toFixed(1);
25552          var rot = (Math.random() * 26 - 13).toFixed(1);
25553          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25554          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';
25555          container.appendChild(el);
25556        })(i);
25557      }
25558    })();
25559    (function randomizeWatermarks() {
25560      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25561      var placed = [];
25562      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; }
25563      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]; }
25564      var half = Math.floor(wms.length/2);
25565      wms.forEach(function(img, i) {
25566        var pos = pick(i < half);
25567        var w = Math.floor(Math.random()*60+80);
25568        var rot = (Math.random()*40-20).toFixed(1);
25569        var op = (Math.random()*0.08+0.05).toFixed(2);
25570        var animDur = (Math.random()*6+5).toFixed(1);
25571        var animDelay = (Math.random()*10).toFixed(1);
25572        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';
25573      });
25574    })();
25575  </script>
25576  <script nonce="{{ csp_nonce }}">
25577  (function(){
25578    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'}];
25579    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);});}
25580    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25581    function init(){
25582      var btn=document.getElementById('settings-btn');if(!btn)return;
25583      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25584      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>';
25585      document.body.appendChild(m);
25586      var g=document.getElementById('scheme-grid');
25587      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);});
25588      var cl=document.getElementById('settings-close');
25589      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);
25590      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');});
25591      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25592      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25593    }
25594    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25595  }());
25596  </script>
25597  <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]';
25598  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;}
25599  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>
25600</body>
25601</html>
25602"##,
25603    ext = "html"
25604)]
25605struct ErrorTemplate {
25606    message: String,
25607    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
25608    last_report_url: Option<String>,
25609    /// Label for the secondary action button; defaults to "View last report" when None.
25610    last_report_label: Option<String>,
25611    /// Run ID to surface in the bug report; `None` when not applicable.
25612    run_id: Option<String>,
25613    /// HTTP status code to surface in the bug report; `None` when unknown.
25614    error_code: Option<u16>,
25615    csp_nonce: String,
25616    version: &'static str,
25617}
25618
25619// ── LocateFileTemplate ────────────────────────────────────────────────────────
25620
25621#[derive(Template)]
25622#[template(
25623    source = r##"
25624<!doctype html>
25625<html lang="en">
25626<head>
25627  <meta charset="utf-8">
25628  <meta name="viewport" content="width=device-width, initial-scale=1">
25629  <title>OxideSLOC | Locate Report</title>
25630  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25631  <style nonce="{{ csp_nonce }}">
25632    :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);}
25633    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25634    *{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;}
25635    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25636    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25637    .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);}
25638    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25639    .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));}
25640    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25641    .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;}
25642    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25643    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25644    @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;}}
25645    .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;}
25646    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25647    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25648    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25649    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25650    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25651    .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;}
25652    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25653    .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);}
25654    .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;}
25655    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25656    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25657    .settings-modal-body{padding:14px 16px 16px;}
25658    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25659    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25660    .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;}
25661    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25662    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25663    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25664    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25665    .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;}
25666    .tz-select:focus{border-color:var(--oxide);}
25667    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25668    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25669    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25670    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25671    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25672    .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;}
25673    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25674    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25675    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25676    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25677    .locate-row{display:flex;gap:8px;align-items:stretch;}
25678    .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;}
25679    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25680    body.dark-theme .locate-input{background:var(--surface-2);}
25681    .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;}
25682    .warning-banner.show{display:flex;}
25683    .warning-banner svg{flex:0 0 auto;}
25684    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25685    .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;}
25686    .error-inline.show{display:flex;}
25687    .error-inline svg{flex:0 0 auto;margin-top:2px;}
25688    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25689    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25690    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25691    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25692    .err-kv-p{margin:0 0 4px;}
25693    .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;}
25694    .success-inline.show{display:flex;}
25695    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25696    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25697    .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;}
25698    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25699    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25700    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25701    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25702    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25703    .fh-row:last-child{border-bottom:none;}
25704    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25705    .fh-dir{font-weight:800;color:var(--text);}
25706    .fh-hl{color:var(--oxide);font-weight:700;}
25707    .fh-muted{color:var(--muted);}
25708    .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;}
25709    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25710    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25711    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25712    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25713    .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;}
25714    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25715    .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;}
25716    .btn-secondary:hover{background:var(--line);}
25717    .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;}
25718    .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;}
25719    .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;}
25720    @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));}}
25721    .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;}
25722    .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;}
25723    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25724  </style>
25725</head>
25726<body>
25727  <div class="background-watermarks" aria-hidden="true">
25728    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25729    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25730    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25731    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25732    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25733    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25734  </div>
25735  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25736  <div class="top-nav">
25737    <div class="top-nav-inner">
25738      <a class="brand" href="/">
25739        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25740        <div class="brand-copy">
25741          <div class="brand-title">OxideSLOC</div>
25742          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25743        </div>
25744      </a>
25745      <div class="nav-right">
25746        <a class="nav-pill" href="/">Home</a>
25747        <div class="nav-dropdown">
25748          <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>
25749          <div class="nav-dropdown-menu">
25750            <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>
25751          </div>
25752        </div>
25753        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25754        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25755        <div class="nav-dropdown">
25756          <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>
25757          <div class="nav-dropdown-menu">
25758            <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>
25759          </div>
25760        </div>
25761        <div class="server-status-wrap" id="server-status-wrap">
25762          <div class="nav-pill server-online-pill" id="server-status-pill">
25763            <span class="status-dot" id="status-dot"></span>
25764            <span id="server-status-label">Server</span>
25765            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25766          </div>
25767          <div class="server-status-tip">
25768            OxideSLOC is running &mdash; accessible on your network.
25769            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25770          </div>
25771        </div>
25772        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25773          <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>
25774        </button>
25775        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25776          <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>
25777          <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>
25778        </button>
25779      </div>
25780    </div>
25781  </div>
25782
25783  <div class="page">
25784    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25785    <div class="panel">
25786      <h1>Report File Not Found</h1>
25787      <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>
25788      <div class="field-label">Missing file</div>
25789      <div class="filename-chip">
25790        <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>
25791        {{ expected_filename }}
25792      </div>
25793      <div class="locate-section">
25794        <h2>Locate Scan Output Folder</h2>
25795        <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>
25796        <p>OxideSLOC will find the correct files inside automatically.</p>
25797        <div class="locate-row">
25798          <input type="text" id="locate-file-input"
25799                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25800                 class="locate-input" autocomplete="off" spellcheck="false">
25801          {% if !server_mode %}
25802          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
25803          {% endif %}
25804        </div>
25805        <div class="warning-banner" id="filename-warning">
25806          <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>
25807          <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>
25808        </div>
25809        <div class="error-inline" id="locate-error">
25810          <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>
25811          <span id="locate-error-text"></span>
25812        </div>
25813        <div class="success-inline" id="locate-success">
25814          <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>
25815          <span>Scan restored &mdash; loading report&hellip;</span>
25816        </div>
25817        <div class="btn-row">
25818          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25819          <a class="btn-secondary" href="/view-reports">View Reports</a>
25820        </div>
25821        <div class="folder-hint-shell">
25822          <div class="folder-hint-hdr">
25823            <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>
25824            Expected Folder Structure &mdash; Select the Top-Level Folder
25825          </div>
25826          <div class="folder-hint-body">
25827            <div class="fh-row">
25828              <span class="fh-tog">&#9658;</span>
25829              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
25830              <span class="fh-badge">&larr; select this</span>
25831            </div>
25832            <div class="fh-row fh-i1">
25833              <span class="fh-tog">&#9658;</span>
25834              <span class="fh-dir">html/</span>
25835            </div>
25836            <div class="fh-row fh-i2">
25837              <span class="fh-bul">&#8226;</span>
25838              <span class="fh-hl">{{ expected_filename }}</span>
25839            </div>
25840            <div class="fh-row fh-i1">
25841              <span class="fh-tog">&#9658;</span>
25842              <span class="fh-dir">json/</span>
25843            </div>
25844            <div class="fh-row fh-i2">
25845              <span class="fh-bul">&#8226;</span>
25846              <span class="fh-muted">result_*.json</span>
25847            </div>
25848            <div class="fh-row fh-i1">
25849              <span class="fh-tog">&#9658;</span>
25850              <span class="fh-dir">pdf/</span>
25851            </div>
25852            <div class="fh-row fh-i2">
25853              <span class="fh-bul">&#8226;</span>
25854              <span class="fh-muted">report_*.pdf</span>
25855            </div>
25856            <div class="fh-row fh-i1">
25857              <span class="fh-tog">&#9658;</span>
25858              <span class="fh-dir">excel/</span>
25859            </div>
25860            <div class="fh-row fh-i2">
25861              <span class="fh-bul">&#8226;</span>
25862              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
25863            </div>
25864          </div>
25865        </div>
25866      </div>
25867    </div>
25868  </div>
25869  <footer class="site-footer">
25870    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25871    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25872    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25873    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25874    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25875  </footer>
25876  <script nonce="{{ csp_nonce }}">(function(){
25877    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25878    if(s==="dark")b.classList.add("dark-theme");
25879    document.getElementById("theme-toggle").addEventListener("click",function(){
25880      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
25881    });
25882  })();</script>
25883  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
25884    var c=document.getElementById('code-particles');if(!c)return;
25885    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'];
25886    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);}
25887  })();
25888  (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>
25889  <script nonce="{{ csp_nonce }}">(function(){
25890    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'}];
25891    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);});}
25892    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25893    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');});}
25894    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25895  }());</script>
25896  <script nonce="{{ csp_nonce }}">(function(){
25897    var meta=document.getElementById('locate-meta');
25898    var inp=document.getElementById('locate-file-input');
25899    var browseBtn=document.getElementById('browse-locate-btn');
25900    var submitBtn=document.getElementById('locate-submit-btn');
25901    var warning=document.getElementById('filename-warning');
25902    var errBox=document.getElementById('locate-error');
25903    var errText=document.getElementById('locate-error-text');
25904    var okBox=document.getElementById('locate-success');
25905    var expected=meta?meta.getAttribute('data-expected'):'';
25906    var runId=meta?meta.getAttribute('data-run-id'):'';
25907    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
25908    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
25909    function showErr(msg){
25910      if(errText){
25911        errText.innerHTML='';
25912        var lines=msg.split('\n');
25913        var hasPairs=lines.some(function(l){return / : /.test(l);});
25914        if(!hasPairs){errText.textContent=msg;}
25915        else{
25916          var frag=document.createDocumentFragment();var tbl=null;
25917          lines.forEach(function(line){
25918            var m=line.match(/^(.*?) : (.*)$/);
25919            if(m){
25920              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
25921              var tr=document.createElement('tr');
25922              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
25923              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
25924              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
25925            } else {
25926              tbl=null;
25927              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
25928            }
25929          });
25930          errText.appendChild(frag);
25931        }
25932      }
25933      if(errBox)errBox.classList.add('show');
25934      if(okBox)okBox.classList.remove('show');
25935    }
25936    function clearErr(){
25937      if(errBox)errBox.classList.remove('show');
25938      if(okBox)okBox.classList.remove('show');
25939    }
25940    function validate(){
25941      var val=inp?inp.value.trim():'';
25942      clearErr();
25943      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
25944      if(submitBtn)submitBtn.disabled=false;
25945      if(warning){
25946        var name=basename(val);
25947        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
25948        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
25949        else warning.classList.remove('show');
25950      }
25951    }
25952    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
25953    if(browseBtn){
25954      browseBtn.addEventListener('click',function(){
25955        browseBtn.disabled=true;browseBtn.textContent='...';
25956        fetch('/pick-directory')
25957          .then(function(r){return r.ok?r.json():{cancelled:true};})
25958          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
25959          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
25960      });
25961    }
25962    if(submitBtn){
25963      submitBtn.addEventListener('click',function(){
25964        var folder=inp?inp.value.trim():'';
25965        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
25966        clearErr();
25967        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
25968        var body=new URLSearchParams();
25969        body.set('file_path',folder);
25970        body.set('redirect_url',redirectUrl);
25971        body.set('expected_run_id',runId);
25972        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
25973          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
25974          .then(function(d){
25975            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25976            if(d&&d.ok){
25977              if(okBox)okBox.classList.add('show');
25978              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
25979            } else {
25980              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
25981            }
25982          })
25983          .catch(function(e){
25984            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25985            showErr('Network error: '+String(e));
25986          });
25987      });
25988    }
25989  })();</script>
25990  <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>
25991</body>
25992</html>
25993"##,
25994    ext = "html"
25995)]
25996struct LocateFileTemplate {
25997    run_id: String,
25998    artifact_type: String,
25999    expected_filename: String,
26000    server_mode: bool,
26001    csp_nonce: String,
26002    version: &'static str,
26003}
26004
26005// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26006
26007#[derive(Template)]
26008#[template(
26009    source = r##"
26010<!doctype html>
26011<html lang="en">
26012<head>
26013  <meta charset="utf-8">
26014  <meta name="viewport" content="width=device-width, initial-scale=1">
26015  <title>OxideSLOC | Locate Scan Files</title>
26016  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26017  <style nonce="{{ csp_nonce }}">
26018    :root {
26019      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26020      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26021      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26022      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26023    }
26024    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26025    *{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;}
26026    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26027    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26028    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26029    .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);}
26030    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26031    .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));}
26032    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26033    .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;}
26034    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26035    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26036    @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;}}
26037    .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;}
26038    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26039    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26040    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26041    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26042    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26043    .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;}
26044    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26045    .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);}
26046    .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;}
26047    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26048    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26049    .settings-modal-body{padding:14px 16px 16px;}
26050    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26051    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26052    .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;}
26053    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26054    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26055    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26056    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26057    .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;}
26058    .tz-select:focus{border-color:var(--oxide);}
26059    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26060    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26061    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26062    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26063    .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;}
26064    .error-box.hidden{display:none;}
26065    .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;}
26066    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26067    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26068    .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;}
26069    .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;}
26070    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26071    .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;}
26072    .btn-secondary:hover{background:var(--line);}
26073    .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;}
26074    .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;}
26075    .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;}
26076    @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));}}
26077    .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;}
26078    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26079    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26080    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26081    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26082    .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;}
26083    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26084    body.dark-theme .relocate-input{background:var(--surface-2);}
26085  </style>
26086</head>
26087<body>
26088  <div class="background-watermarks" aria-hidden="true">
26089    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26090    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26091    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26092    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26093    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26094    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26095  </div>
26096  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26097  <div class="top-nav">
26098    <div class="top-nav-inner">
26099      <a class="brand" href="/">
26100        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26101        <div class="brand-copy">
26102          <div class="brand-title">OxideSLOC</div>
26103          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26104        </div>
26105      </a>
26106      <div class="nav-right">
26107        <a class="nav-pill" href="/">Home</a>
26108        <div class="nav-dropdown">
26109          <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>
26110          <div class="nav-dropdown-menu">
26111            <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>
26112          </div>
26113        </div>
26114        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26115        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26116        <div class="nav-dropdown">
26117          <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>
26118          <div class="nav-dropdown-menu">
26119            <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>
26120          </div>
26121        </div>
26122        <div class="server-status-wrap" id="server-status-wrap">
26123          <div class="nav-pill server-online-pill" id="server-status-pill">
26124            <span class="status-dot" id="status-dot"></span>
26125            <span id="server-status-label">Server</span>
26126            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26127          </div>
26128          <div class="server-status-tip">
26129            OxideSLOC is running — accessible on your network.
26130            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26131          </div>
26132        </div>
26133        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26134          <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>
26135        </button>
26136        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26137          <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>
26138          <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>
26139        </button>
26140      </div>
26141    </div>
26142  </div>
26143
26144  <div class="page">
26145    <div class="panel">
26146      <h1>Scan Files Moved</h1>
26147      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26148      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26149      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26150      <div class="relocate-section">
26151        <h2>Locate Scan Output</h2>
26152        <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>
26153        <div class="relocate-row">
26154          <input type="text" id="relocate-folder" name="folder_path"
26155                 value="{{ folder_hint }}"
26156                 placeholder="Path to folder containing scan output..."
26157                 class="relocate-input" autocomplete="off" spellcheck="false">
26158          {% if !server_mode %}
26159          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
26160          {% endif %}
26161        </div>
26162        <div style="margin-top:12px;">
26163          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26164        </div>
26165      </div>
26166      <div class="actions">
26167        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26168        <a class="btn-secondary" href="/view-reports">View Reports</a>
26169      </div>
26170    </div>
26171  </div>
26172  <footer class="site-footer">
26173    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
26174    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26175    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26176    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26177    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26178  </footer>
26179  <script nonce="{{ csp_nonce }}">
26180    (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");});})();
26181    (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);}})();
26182    (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;});})();
26183  </script>
26184  <script nonce="{{ csp_nonce }}">
26185  (function(){
26186    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'}];
26187    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);});}
26188    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26189    function init(){
26190      var btn=document.getElementById('settings-btn');if(!btn)return;
26191      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26192      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>';
26193      document.body.appendChild(m);
26194      var g=document.getElementById('scheme-grid');
26195      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);});
26196      var cl=document.getElementById('settings-close');
26197      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);
26198      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');});
26199      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26200      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26201    }
26202    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26203  }());
26204  (function(){
26205    var browseBtn=document.getElementById('browse-relocate-btn');
26206    if(browseBtn){
26207      browseBtn.addEventListener('click',function(){
26208        browseBtn.disabled=true;browseBtn.textContent='...';
26209        var inp=document.getElementById('relocate-folder');
26210        var hint=inp?inp.value:'';
26211        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
26212          .then(function(r){return r.ok?r.json():{cancelled:true};})
26213          .then(function(d){
26214            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26215            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26216          })
26217          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26218      });
26219    }
26220    var restoreBtn=document.getElementById('restore-btn');
26221    var errBox=document.getElementById('relocate-error-box');
26222    var okBox=document.getElementById('relocate-success-box');
26223    if(restoreBtn){
26224      restoreBtn.addEventListener('click',function(){
26225        var inp=document.getElementById('relocate-folder');
26226        var folder=inp?inp.value.trim():'';
26227        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26228        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26229        var body=new URLSearchParams();
26230        body.set('run_id','{{ run_id }}');
26231        body.set('redirect_url','{{ redirect_url }}');
26232        body.set('folder_path',folder);
26233        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26234          .then(function(r){return r.json();})
26235          .then(function(d){
26236            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26237            if(d&&d.ok){
26238              if(errBox)errBox.classList.add('hidden');
26239              if(okBox){okBox.style.display='block';}
26240              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26241            } else {
26242              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26243            }
26244          })
26245          .catch(function(e){
26246            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26247            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26248          });
26249      });
26250    }
26251  }());
26252  </script>
26253  <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]';
26254  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;}
26255  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>
26256</body>
26257</html>
26258"##,
26259    ext = "html"
26260)]
26261struct RelocateScanTemplate {
26262    message: String,
26263    run_id: String,
26264    folder_hint: String,
26265    redirect_url: String,
26266    server_mode: bool,
26267    csp_nonce: String,
26268    version: &'static str,
26269}
26270
26271// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
26272
26273#[derive(Template)]
26274#[template(
26275    source = r##"
26276<!doctype html>
26277<html lang="en">
26278<head>
26279  <meta charset="utf-8">
26280  <meta name="viewport" content="width=device-width, initial-scale=1">
26281  <title>OxideSLOC | View Reports</title>
26282  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26283  <style nonce="{{ csp_nonce }}">
26284    :root {
26285      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26286      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26287      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26288      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26289      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26290    }
26291    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; }
26292    *{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;}
26293    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26294    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26295    .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);}
26296    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26297    .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));}
26298    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26299    .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;}
26300    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26301    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26302    @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; } }
26303    .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;}
26304    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26305    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26306    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26307    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26308    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26309    .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;}
26310    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26311    .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);}
26312    .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;}
26313    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26314    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26315    .settings-modal-body{padding:14px 16px 16px;}
26316    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26317    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26318    .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;}
26319    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26320    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26321    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26322    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26323    .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;}
26324    .tz-select:focus{border-color:var(--oxide);}
26325    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26326    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26327    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26328    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26329    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26330    .panel-meta{font-size:13px;color:var(--muted);}
26331    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26332    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26333    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26334    .per-page-label{font-size:13px;color:var(--muted);}
26335    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;}
26336    .filter-input{min-width:180px;cursor:text;}
26337    .table-wrap{width:100%;overflow-x:auto;}
26338    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26339    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;}
26340    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26341    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26342    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26343    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26344    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26345    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26346    tr:last-child td{border-bottom:none;}
26347    tr:hover td{background:var(--surface-2);}
26348    .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);}
26349    .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);}
26350    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26351    .metric-num{font-weight:700;color:var(--text);}
26352    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26353    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26354    .git-commit-chip{cursor:help;}
26355    .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;}
26356    .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;}
26357    .btn:hover{background:var(--line);}
26358    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26359    .btn.primary:hover{opacity:.9;}
26360    .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;}
26361    .btn-back:hover{background:var(--line);}
26362    .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;}
26363    .export-btn:hover{background:var(--line);}
26364    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26365    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26366    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26367    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26368    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26369    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26370    .pagination-info{font-size:13px;color:var(--muted);}
26371    .pagination-btns{display:flex;gap:6px;}
26372    .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;}
26373    .pg-btn:hover:not(:disabled){background:var(--line);}
26374    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26375    .pg-btn:disabled{opacity:.35;cursor:default;}
26376    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26377    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26378    .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);}
26379    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26380    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26381    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26382    .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);}
26383    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26384    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26385    .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;}
26386    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26387    .site-footer a{color:var(--muted);}
26388    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26389    .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%;}
26390    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26391    .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;}
26392    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26393    .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;}
26394    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26395    .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;}
26396    .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;}
26397    .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;}
26398    @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));}}
26399    .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;}
26400    .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;}
26401    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26402    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26403    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26404    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26405    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26406    .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;}
26407    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26408    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26409    .watched-chip-rm:hover{color:var(--oxide);}
26410    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26411    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26412    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26413    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26414    .rpt-btn{min-width:58px;justify-content:center;}
26415    .flex-row{display:flex;align-items:center;gap:8px;}
26416    .report-cell{overflow:visible;white-space:normal;}
26417    #history-table col:nth-child(1){width:185px;}
26418    #history-table col:nth-child(2){width:220px;}
26419    #history-table col:nth-child(3){width:100px;}
26420    #history-table col:nth-child(4){width:72px;}
26421    #history-table col:nth-child(5){width:82px;}
26422    #history-table col:nth-child(6){width:82px;}
26423    #history-table col:nth-child(7){width:65px;}
26424    #history-table col:nth-child(8){width:90px;}
26425    #history-table col:nth-child(9){width:85px;}
26426    #history-table col:nth-child(10){width:115px;}
26427    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26428    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26429    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26430    .submod-details summary::-webkit-details-marker{display:none;}
26431.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26432    .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;}
26433    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26434    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26435  </style>
26436</head>
26437<body>
26438  <div class="background-watermarks" aria-hidden="true">
26439    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26440    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26441    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26442    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26443    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26444    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26445  </div>
26446  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26447  <div class="top-nav">
26448    <div class="top-nav-inner">
26449      <a class="brand" href="/">
26450        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26451        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26452      </a>
26453      <div class="nav-right">
26454        <a class="nav-pill" href="/">Home</a>
26455        <div class="nav-dropdown">
26456          <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>
26457          <div class="nav-dropdown-menu">
26458            <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>
26459          </div>
26460        </div>
26461        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26462        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26463        <div class="nav-dropdown">
26464          <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>
26465          <div class="nav-dropdown-menu">
26466            <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>
26467          </div>
26468        </div>
26469        <div class="server-status-wrap" id="server-status-wrap">
26470          <div class="nav-pill server-online-pill" id="server-status-pill">
26471            <span class="status-dot" id="status-dot"></span>
26472            <span id="server-status-label">Server</span>
26473            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26474          </div>
26475          <div class="server-status-tip">
26476            OxideSLOC is running — accessible on your network.
26477            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26478          </div>
26479        </div>
26480        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26481          <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>
26482        </button>
26483        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26484          <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>
26485          <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>
26486        </button>
26487      </div>
26488    </div>
26489  </div>
26490
26491  <div class="page">
26492    {% if let Some(err) = browse_error %}
26493    <div class="toast-error">
26494      <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>
26495      {{ err }}
26496    </div>
26497    {% endif %}
26498    {% if linked_count > 0 %}
26499    <div class="toast-success">
26500      <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>
26501      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26502    </div>
26503    {% endif %}
26504    <div class="watched-bar">
26505      <div class="watched-bar-left">
26506        <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>
26507        <span class="watched-label">Watched Folders</span>
26508        <div class="watched-chips">
26509          {% if server_mode %}
26510          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26511          {% else %}
26512          {% for dir in watched_dirs %}
26513          <span class="watched-chip">
26514            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26515            <form method="POST" action="/watched-dirs/remove" style="display:contents">
26516              <input type="hidden" name="folder_path" value="{{ dir }}">
26517              <input type="hidden" name="redirect_to" value="/view-reports">
26518              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
26519            </form>
26520          </span>
26521          {% endfor %}
26522          {% if watched_dirs.is_empty() %}
26523          <span class="watched-none">No folders watched — click Choose to add one</span>
26524          {% endif %}
26525          {% endif %}
26526        </div>
26527      </div>
26528      {% if !server_mode %}
26529      <div class="watched-bar-right">
26530        <button type="button" class="btn" id="add-watched-btn">
26531          <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>
26532          Choose
26533        </button>
26534        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26535          <input type="hidden" name="redirect_to" value="/view-reports">
26536          <button type="submit" class="btn">&#8635; Refresh</button>
26537        </form>
26538      </div>
26539      {% endif %}
26540    </div>
26541    {% if total_scans > 0 %}
26542    <div class="summary-strip">
26543      <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>
26544      <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>
26545      <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>
26546      <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>
26547    </div>
26548    {% endif %}
26549
26550    <section class="panel">
26551      <div class="panel-header">
26552        <div>
26553          <h1>View Reports</h1>
26554          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26555          {% 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 %}
26556        </div>
26557        <div class="flex-row">
26558          <button type="button" class="export-btn" id="export-csv-btn">
26559            <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>
26560            Export CSV
26561          </button>
26562          <button type="button" class="export-btn" id="export-xls-btn">
26563            <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>
26564            Export Excel
26565          </button>
26566        </div>
26567      </div>
26568
26569      {% if entries.is_empty() %}
26570      <div class="empty-state">
26571        <strong>No reports with viewable HTML yet</strong>
26572        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.
26573      </div>
26574      {% else %}
26575      <div class="filter-row">
26576        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
26577        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26578        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
26579      </div>
26580      <div class="table-wrap">
26581        <table id="history-table">
26582          <colgroup>
26583            <col><col><col><col><col><col><col><col><col><col>
26584          </colgroup>
26585          <thead>
26586            <tr id="history-thead">
26587              <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>
26588              <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>
26589              <th>Run ID<div class="col-resize-handle"></div></th>
26590              <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>
26591              <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>
26592              <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>
26593              <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>
26594              <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>
26595              <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>
26596              <th>Report<div class="col-resize-handle"></div></th>
26597            </tr>
26598          </thead>
26599          <tbody id="history-tbody">
26600            {% for entry in entries %}
26601            <tr class="history-row" data-run="{{ entry.run_id }}"
26602                data-timestamp="{{ entry.timestamp }}"
26603                data-project="{{ entry.project_label }}"
26604                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26605                data-skipped="{{ entry.files_skipped }}"
26606                data-comments="{{ entry.comment_lines }}"
26607                data-blank="{{ entry.blank_lines }}"
26608                data-physical="{{ entry.total_physical_lines }}"
26609                data-functions="{{ entry.functions }}"
26610                data-classes="{{ entry.classes }}"
26611                data-variables="{{ entry.variables }}"
26612                data-imports="{{ entry.imports }}"
26613                data-tests="{{ entry.test_count }}"
26614                data-branch="{{ entry.git_branch }}"
26615                data-commit="{{ entry.git_commit }}"
26616                data-has-json="{{ entry.has_json }}"
26617                data-html-url="/runs/html/{{ entry.run_id }}">
26618              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26619              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26620              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26621              <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>
26622              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26623              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26624              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26625              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
26626              <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>
26627              <td class="report-cell">
26628                <div class="actions-cell">
26629                  {% 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 %}
26630                  {% 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 %}
26631                </div>
26632                {% if !entry.submodule_links.is_empty() %}
26633                <details class="submod-details">
26634                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
26635                  <div class="submod-link-list">
26636                    {% for sub in entry.submodule_links %}
26637                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26638                    {% endfor %}
26639                  </div>
26640                </details>
26641                {% endif %}
26642              </td>
26643            </tr>
26644            {% endfor %}
26645          </tbody>
26646        </table>
26647      </div>
26648      <div class="pagination">
26649        <span class="pagination-info" id="pagination-info"></span>
26650        <div class="pagination-btns" id="pagination-btns"></div>
26651        <div class="flex-row">
26652          <span class="per-page-label">Show</span>
26653          <select class="per-page" id="per-page-sel">
26654            <option value="10">10 per page</option>
26655            <option value="25" selected>25 per page</option>
26656            <option value="50">50 per page</option>
26657            <option value="100">100 per page</option>
26658          </select>
26659          <span class="per-page-label" id="page-range-label"></span>
26660        </div>
26661      </div>
26662      {% endif %}
26663    </section>
26664  </div>
26665
26666  <footer class="site-footer">
26667    local code analysis - metrics, history and reports
26668    &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>
26669    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26670    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26671    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26672    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26673  </footer>
26674
26675  <script nonce="{{ csp_nonce }}">
26676    (function () {
26677      // ── Theme ──────────────────────────────────────────────────────────────
26678      var storageKey = 'oxide-sloc-theme';
26679      var body = document.body;
26680      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26681      var toggle = document.getElementById('theme-toggle');
26682      if (toggle) toggle.addEventListener('click', function () {
26683        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26684        body.classList.toggle('dark-theme', next === 'dark');
26685        try { localStorage.setItem(storageKey, next); } catch(e) {}
26686      });
26687
26688      // ── State ─────────────────────────────────────────────────────────────
26689      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26690      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26691      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26692
26693      // Aggregate stats from first (most recent) row
26694      if (allRows.length) {
26695        var first = allRows[0];
26696        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();}
26697        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>':'');}
26698        setChipVal('agg-code', first.dataset.code);
26699        setChipVal('agg-files', first.dataset.files);
26700        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26701        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26702        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(); });
26703      }
26704
26705      // ── Branch filter population ──────────────────────────────────────────
26706      (function() {
26707        var branches = {};
26708        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26709        var sel = document.getElementById('branch-filter');
26710        if (sel) Object.keys(branches).sort().forEach(function(b) {
26711          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26712        });
26713      })();
26714
26715      // ── Filter ────────────────────────────────────────────────────────────
26716      function getFilteredRows() {
26717        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26718        var branch = ((document.getElementById('branch-filter') || {}).value || '');
26719        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26720          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26721          if (branch && (r.dataset.branch || '') !== branch) return false;
26722          return true;
26723        });
26724      }
26725
26726      // ── Pagination ────────────────────────────────────────────────────────
26727      function renderPage() {
26728        var filtered = getFilteredRows();
26729        var total = filtered.length;
26730        var totalPages = Math.max(1, Math.ceil(total / perPage));
26731        currentPage = Math.min(currentPage, totalPages);
26732        var start = (currentPage - 1) * perPage;
26733        var end = Math.min(start + perPage, total);
26734        var shown = {};
26735        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26736        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26737          r.style.display = shown[r.dataset.run] ? '' : 'none';
26738        });
26739        var rl = document.getElementById('page-range-label');
26740        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26741        var info = document.getElementById('pagination-info');
26742        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26743        var btns = document.getElementById('pagination-btns');
26744        if (!btns) return;
26745        btns.innerHTML = '';
26746        function makeBtn(lbl, pg, active, disabled) {
26747          var b = document.createElement('button');
26748          b.className = 'pg-btn' + (active ? ' active' : '');
26749          b.textContent = lbl; b.disabled = disabled;
26750          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26751          return b;
26752        }
26753        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26754        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26755        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26756        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26757      }
26758
26759      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26760      window.applyFilters = function() { currentPage = 1; renderPage(); };
26761
26762      // ── Sorting ───────────────────────────────────────────────────────────
26763      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26764      function doSort(col, type, order) {
26765        var tbody = document.getElementById('history-tbody');
26766        if (!tbody) return;
26767        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26768        rows.sort(function(a, b) {
26769          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26770          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26771          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26772          return va < vb ? 1 : va > vb ? -1 : 0;
26773        });
26774        rows.forEach(function(r) { tbody.appendChild(r); });
26775        currentPage = 1; renderPage();
26776      }
26777      sortHeaders.forEach(function(th) {
26778        th.addEventListener('click', function(e) {
26779          if (e.target.classList.contains('col-resize-handle')) return;
26780          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26781          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26782          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26783          th.classList.add('sort-' + sortOrder);
26784          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26785          doSort(col, type, sortOrder);
26786        });
26787      });
26788
26789      // ── Column resize ─────────────────────────────────────────────────────
26790      (function() {
26791        var table = document.getElementById('history-table');
26792        if (!table) return;
26793        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26794        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26795        ths.forEach(function(th, i) {
26796          var handle = th.querySelector('.col-resize-handle');
26797          if (!handle || !cols[i]) return;
26798          var startX, startW;
26799          handle.addEventListener('mousedown', function(e) {
26800            e.stopPropagation(); e.preventDefault();
26801            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26802            handle.classList.add('dragging');
26803            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26804            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26805            document.addEventListener('mousemove', onMove);
26806            document.addEventListener('mouseup', onUp);
26807          });
26808        });
26809      })();
26810
26811      // ── Full-commit hover tooltip ─────────────────────────────────────────
26812      // The commit chips live inside an overflow:auto table wrapper, which would
26813      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26814      // (escaping the scroll container) and follow the cursor. Event delegation
26815      // keeps it working after pagination/sorting re-renders the rows.
26816      (function() {
26817        var tip = document.createElement('div');
26818        tip.className = 'commit-tip';
26819        tip.setAttribute('role', 'tooltip');
26820        document.body.appendChild(tip);
26821        var shown = false;
26822        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26823        function place(e) {
26824          var pad = 14, r = tip.getBoundingClientRect();
26825          var x = e.clientX + pad, y = e.clientY + pad;
26826          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26827          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26828          tip.style.left = x + 'px'; tip.style.top = y + 'px';
26829        }
26830        function hide() { tip.style.display = 'none'; shown = false; }
26831        document.addEventListener('mouseover', function(e) {
26832          var chip = chipFrom(e.target);
26833          if (!chip) return;
26834          var full = chip.getAttribute('data-full-commit');
26835          if (!full) return;
26836          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26837        });
26838        document.addEventListener('mousemove', function(e) {
26839          if (!shown) return;
26840          if (chipFrom(e.target)) place(e); else hide();
26841        });
26842        document.addEventListener('mouseout', function(e) {
26843          if (chipFrom(e.target)) hide();
26844        });
26845      })();
26846
26847      // ── Reset view ────────────────────────────────────────────────────────
26848      window.resetView = function() {
26849        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26850        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26851        sortCol = null; sortOrder = 'asc';
26852        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26853        var tbody = document.getElementById('history-tbody');
26854        if (tbody) {
26855          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26856          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26857          rows.forEach(function(r) { tbody.appendChild(r); });
26858        }
26859        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26860        var table = document.getElementById('history-table');
26861        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26862        currentPage = 1; renderPage();
26863      };
26864
26865      renderPage();
26866
26867      // ── Export helpers ────────────────────────────────────────────────────
26868      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
26869      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26870      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);}
26871      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;');}
26872      function slocXlsx(fname,sheet,hdrs,rows){
26873        var enc=new TextEncoder();
26874        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;}
26875        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;}
26876        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26877        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26878        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26879        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;}
26880        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26881        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];}
26882        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26883        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
26884        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26885          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26886          +'<fonts count="2">'
26887            +'<font><sz val="11"/><name val="Calibri"/></font>'
26888            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26889          +'</fonts>'
26890          +'<fills count="3">'
26891            +'<fill><patternFill patternType="none"/></fill>'
26892            +'<fill><patternFill patternType="gray125"/></fill>'
26893            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26894          +'</fills>'
26895          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
26896          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
26897          +'<cellXfs count="4">'
26898            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
26899            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
26900            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
26901            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
26902          +'</cellXfs>'
26903          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
26904          +'</styleSheet>';
26905        var rx='<row r="1">';
26906        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
26907        rx+='</row>';
26908        rows.forEach(function(row,ri){
26909          var rn=ri+2;rx+='<row r="'+rn+'">';
26910          row.forEach(function(cell,c){
26911            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
26912            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
26913            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
26914            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
26915            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
26916            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
26917          });
26918          rx+='</row>';
26919        });
26920        var lastCol=hdrs.length,lastRow=rows.length+1;
26921        var tableRef='A1:'+colNm(lastCol)+lastRow;
26922        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26923          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
26924          +'<autoFilter ref="'+tableRef+'"/>'
26925          +'<tableColumns count="'+lastCol+'">'
26926          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
26927          +'</tableColumns>'
26928          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
26929          +'</table>';
26930        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26931          +'<Relationships xmlns="'+pns+'relationships">'
26932          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
26933          +'</Relationships>';
26934        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>';
26935        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
26936          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
26937          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
26938          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
26939          +'</worksheet>';
26940        var F={
26941          '[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>',
26942          '_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>',
26943          '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>',
26944          '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>',
26945          'xl/styles.xml':stl,
26946          'xl/sharedStrings.xml':ssXml,
26947          'xl/worksheets/sheet1.xml':sh,
26948          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
26949          'xl/tables/table1.xml':tableXml
26950        };
26951        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'];
26952        var zparts=[],zcds=[],zoff=0,znf=0;
26953        order.forEach(function(name){
26954          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26955          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]);
26956          var entry=new Uint8Array(lha.length+nb.length+sz);
26957          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26958          zparts.push(entry);
26959          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));
26960          var cde=new Uint8Array(cda.length+nb.length);
26961          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26962          zcds.push(cde);zoff+=entry.length;znf++;
26963        });
26964        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26965        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]);
26966        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26967        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26968        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26969        zout.set(new Uint8Array(ea),zpos);
26970        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
26971      }
26972
26973      // Multi-sheet XLSX builder for the scan-history export.
26974      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
26975      function slocXlsxMulti(fname,sheets){
26976        var enc=new TextEncoder();
26977        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;}
26978        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;}
26979        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26980        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26981        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26982        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];}
26983        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;}
26984        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26985        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26986        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26987          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26988          +'<fonts count="3">'
26989            +'<font><sz val="11"/><name val="Calibri"/></font>'
26990            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26991            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
26992          +'</fonts>'
26993          +'<fills count="4">'
26994            +'<fill><patternFill patternType="none"/></fill>'
26995            +'<fill><patternFill patternType="gray125"/></fill>'
26996            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26997            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
26998          +'</fills>'
26999          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27000          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27001          +'<cellXfs count="7">'
27002            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27003            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27004            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27005            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27006            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27007            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27008            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27009          +'</cellXfs>'
27010          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27011          +'</styleSheet>';
27012        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27013        sheets.forEach(function(sh,sheetIdx){
27014          var rx='<row r="1">';
27015          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27016          rx+='</row>';
27017          var rn=2;
27018          sh.rows.forEach(function(row){
27019            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27020            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27021              rx+='<row r="'+rn+'">';
27022              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27023              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27024              rx+='</row>';rn++;return;
27025            }
27026            rx+='<row r="'+rn+'">';
27027            row.forEach(function(cell,c){
27028              var ref=colRef(c,rn);
27029              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27030              if(typeof cell==='object'&&cell!==null){
27031                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27032                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27033                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27034                return;
27035              }
27036              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27037              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27038            });
27039            rx+='</row>';rn++;
27040          });
27041          var cw='';
27042          if(sh.colWidths&&sh.colWidths.length>0){
27043            cw='<cols>';
27044            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27045            cw+='</cols>';
27046          }
27047          var tblParts='';
27048          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27049            tableCounter++;
27050            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27051            var tRef='A1:'+colNm(colCount)+rowCount;
27052            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27053              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27054              +'<autoFilter ref="'+tRef+'"/>'
27055              +'<tableColumns count="'+colCount+'">'
27056              +sh.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            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27061              +'<Relationships xmlns="'+pns+'relationships">'
27062              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27063              +'</Relationships>';
27064            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27065          }
27066          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27067            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27068            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27069        });
27070        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>';
27071        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27072        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27073        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>';
27074        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27075        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27076        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27077        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27078          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27079        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27080        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};
27081        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27082        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27083        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27084        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27085        var zparts=[],zcds=[],zoff=0,znf=0;
27086        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++;});
27087        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27088        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]);
27089        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27090        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27091        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27092        zout.set(new Uint8Array(ea),zpos);
27093        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27094      }
27095
27096      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'};
27097      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27098
27099      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'];
27100      function getHistoryRows(){
27101        var r=[];
27102        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27103          var code=Number(tr.getAttribute('data-code'))||0;
27104          var phys=Number(tr.getAttribute('data-physical'))||0;
27105          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27106          r.push([
27107            tr.getAttribute('data-timestamp')||'',
27108            tr.getAttribute('data-project')||'',
27109            tr.getAttribute('data-run')||'',
27110            tr.getAttribute('data-physical')||'',
27111            tr.getAttribute('data-code')||'',
27112            tr.getAttribute('data-comments')||'',
27113            tr.getAttribute('data-blank')||'',
27114            tr.getAttribute('data-files')||'',
27115            tr.getAttribute('data-skipped')||'',
27116            tr.getAttribute('data-functions')||'',
27117            tr.getAttribute('data-classes')||'',
27118            tr.getAttribute('data-variables')||'',
27119            tr.getAttribute('data-imports')||'',
27120            tr.getAttribute('data-tests')||'',
27121            dens,
27122            tr.getAttribute('data-branch')||'',
27123            tr.getAttribute('data-commit')||''
27124          ]);
27125        });
27126        return r;
27127      }
27128      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27129      window.exportHistoryXls = function(){
27130        var histRows=getHistoryRows();
27131        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27132        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]];});
27133        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]};
27134        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27135        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27136        var runId=jsonRow.getAttribute('data-run')||'';
27137        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27138        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27139        fetch('/runs/json/'+runId)
27140          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27141          .then(function(run){
27142            var tot=run.summary_totals||{};
27143            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27144            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27145            function B(v){return{v:v,s:4};}
27146            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27147            var sumRows=[
27148              [{_sec:true,v:'RUN INFORMATION'}],
27149              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27150              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27151              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27152              [B('Branch'),run.git_branch||''],
27153              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27154              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27155              [B('Files Analyzed'),N(tot.files_analyzed)],
27156              [B('Files Skipped'),N(tot.files_skipped)],
27157              [],
27158              [{_sec:true,v:'CODE METRICS'}],
27159              [B('Physical Lines'),N(phys)],
27160              [B('Code Lines'),N(code)],
27161              [B('Comments'),N(tot.comment_lines)],
27162              [B('Blank Lines'),N(tot.blank_lines)],
27163              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27164              [B('Functions'),N(tot.functions)],
27165              [B('Classes / Types'),N(tot.classes)],
27166              [B('Variables'),N(tot.variables)],
27167              [B('Imports'),N(tot.imports)],
27168              [B('Tests'),N(tot.test_count)],
27169              [B('Assertions'),N(tot.test_assertion_count)],
27170              [B('Test Suites'),N(tot.test_suite_count)],
27171              [B('Code Density'),{v:dens,s:6}],
27172              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27173            ];
27174            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27175            var langRows=(run.totals_by_language||[]).map(function(l){
27176              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27177              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27178              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];
27179            });
27180            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27181            var pfRows=(run.per_file_records||[]).map(function(r){
27182              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27183              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];
27184            });
27185            var skHdrs=['File','Status','Size (bytes)'];
27186            var skRows=(run.skipped_file_records||[]).map(function(r){
27187              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27188            });
27189            slocXlsxMulti('scan-history.xlsx',[
27190              histSheet,
27191              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27192              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27193              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27194              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27195            ]);
27196          })
27197          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27198      };
27199
27200      var csvBtn = document.getElementById('export-csv-btn');
27201      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27202      var xlsBtn = document.getElementById('export-xls-btn');
27203      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27204
27205      // ── Remaining CSP-safe event bindings ────────────────────────────────
27206      (function wireEvents() {
27207        var el;
27208        el = document.getElementById('reset-view-btn');
27209        if (el) el.addEventListener('click', window.resetView);
27210        el = document.getElementById('project-filter');
27211        if (el) el.addEventListener('input', window.applyFilters);
27212        el = document.getElementById('branch-filter');
27213        if (el) el.addEventListener('change', window.applyFilters);
27214        el = document.getElementById('per-page-sel');
27215        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27216        el = document.getElementById('add-watched-btn');
27217        if (el) el.addEventListener('click', function() {
27218          fetch('/pick-directory?kind=reports')
27219            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27220            .then(function(data) {
27221              if (!data.cancelled && data.selected_path) {
27222                var form = document.createElement('form');
27223                form.method = 'POST';
27224                form.action = '/watched-dirs/add';
27225                var ri = document.createElement('input');
27226                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27227                var fi = document.createElement('input');
27228                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27229                form.appendChild(ri); form.appendChild(fi);
27230                document.body.appendChild(form);
27231                form.submit();
27232              }
27233            })
27234            .catch(function(e) { alert('Could not open folder picker: ' + e); });
27235        });
27236      })();
27237
27238      (function randomizeWatermarks() {
27239        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27240        if (!wms.length) return;
27241        var placed = [];
27242        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;}
27243        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];}
27244        var half=Math.floor(wms.length/2);
27245        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;});
27246      })();
27247
27248      (function spawnCodeParticles() {
27249        var container = document.getElementById('code-particles');
27250        if (!container) return;
27251        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'];
27252        for (var i = 0; i < 38; i++) {
27253          (function(idx) {
27254            var el = document.createElement('span');
27255            el.className = 'code-particle';
27256            el.textContent = snippets[idx % snippets.length];
27257            var left = Math.random() * 94 + 2;
27258            var top = Math.random() * 88 + 6;
27259            var dur = (Math.random() * 10 + 9).toFixed(1);
27260            var delay = (Math.random() * 18).toFixed(1);
27261            var rot = (Math.random() * 26 - 13).toFixed(1);
27262            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27263            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';
27264            container.appendChild(el);
27265          })(i);
27266        }
27267      })();
27268    })();
27269  </script>
27270  <script nonce="{{ csp_nonce }}">
27271  (function(){
27272    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'}];
27273    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);});}
27274    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27275    function init(){
27276      var btn=document.getElementById('settings-btn');if(!btn)return;
27277      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27278      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>';
27279      document.body.appendChild(m);
27280      var g=document.getElementById('scheme-grid');
27281      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);});
27282      var cl=document.getElementById('settings-close');
27283      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);
27284      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');});
27285      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27286      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27287    }
27288    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27289  }());
27290  </script>
27291  <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>
27292</body>
27293</html>
27294"##,
27295    ext = "html"
27296)]
27297struct HistoryTemplate {
27298    version: &'static str,
27299    entries: Vec<HistoryEntryRow>,
27300    total_scans: usize,
27301    linked_count: usize,
27302    browse_error: Option<String>,
27303    watched_dirs: Vec<String>,
27304    csp_nonce: String,
27305    server_mode: bool,
27306}
27307
27308// ── CompareSelectTemplate ──────────────────────────────────────────────────────
27309
27310#[derive(Template)]
27311#[template(
27312    source = r##"
27313<!doctype html>
27314<html lang="en">
27315<head>
27316  <meta charset="utf-8">
27317  <meta name="viewport" content="width=device-width, initial-scale=1">
27318  <title>OxideSLOC | Compare Scans</title>
27319  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27320  <style nonce="{{ csp_nonce }}">
27321    :root {
27322      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27323      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27324      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27325      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27326      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27327    }
27328    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27329    *{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;}
27330    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27331    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27332    .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);}
27333    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27334    .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));}
27335    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27336    .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;}
27337    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27338    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27339    @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; } }
27340    .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;}
27341    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27342    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27343    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27344    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27345    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27346    .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;}
27347    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27348    .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);}
27349    .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;}
27350    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27351    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27352    .settings-modal-body{padding:14px 16px 16px;}
27353    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27354    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27355    .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;}
27356    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27357    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27358    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27359    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27360    .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;}
27361    .tz-select:focus{border-color:var(--oxide);}
27362    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27363    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27364    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27365    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27366    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27367    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27368    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27369    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27370    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27371    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27372    .per-page-label{font-size:13px;color:var(--muted);}
27373    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;}
27374    .filter-input{min-width:180px;cursor:text;}
27375    .table-wrap{width:100%;overflow-x:auto;}
27376    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27377    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;}
27378    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27379    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27380    #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;}
27381    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27382    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27383    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27384    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27385    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27386    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27387    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27388    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27389    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27390    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27391    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27392    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27393    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27394    tr:last-child td{border-bottom:none;}
27395    tr.selected td{background:var(--sel-bg);}
27396    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27397    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27398    tr{cursor:pointer;}
27399    tr.row-locked{opacity:.35;cursor:not-allowed;}
27400    tr.row-locked td{pointer-events:none;}
27401    .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;}
27402    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27403    .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;}
27404    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27405    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27406    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27407    .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);}
27408    .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);}
27409    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27410    .metric-num{font-weight:700;color:var(--text);}
27411    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27412    .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;}
27413    .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;}
27414    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27415    .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;}
27416    .btn:hover{background:var(--line);}
27417    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27418    .btn.primary:hover{opacity:.9;}
27419    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27420    .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;}
27421    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27422    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27423    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27424    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27425    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27426    .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;}
27427    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27428    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27429    .watched-chip-rm:hover{color:var(--oxide);}
27430    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27431    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27432    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27433    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27434    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27435    .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;}
27436    .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;}
27437    .btn-back:hover{background:var(--line);}
27438    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27439    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27440    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27441    .pagination-info{font-size:13px;color:var(--muted);}
27442    .pagination-btns{display:flex;gap:6px;}
27443    .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;}
27444    .pg-btn:hover:not(:disabled){background:var(--line);}
27445    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27446    .pg-btn:disabled{opacity:.35;cursor:default;}
27447    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27448    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27449    .site-footer a{color:var(--muted);}
27450    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27451    .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;}
27452    .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;}
27453    .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;}
27454    @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));}}
27455    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27456    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27457    .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);}
27458    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27459    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27460    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27461    .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);}
27462    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27463    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27464    .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;}
27465    .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;}
27466    .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%;}
27467    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27468    .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;}
27469    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27470    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27471    .hidden{display:none!important;}
27472    .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%;}
27473    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27474    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27475    .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;}
27476    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27477    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27478    .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;}
27479    .scope-option:hover{background:var(--line);}
27480    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27481    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27482    .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;}
27483    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27484    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27485    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27486    .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;}
27487  </style>
27488</head>
27489<body>
27490  <div class="background-watermarks" aria-hidden="true">
27491    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27492    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27493    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27494    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27495    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27496    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27497  </div>
27498  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27499  <div class="top-nav">
27500    <div class="top-nav-inner">
27501      <a class="brand" href="/">
27502        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27503        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27504      </a>
27505      <div class="nav-right">
27506        <a class="nav-pill" href="/">Home</a>
27507        <div class="nav-dropdown">
27508          <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>
27509          <div class="nav-dropdown-menu">
27510            <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>
27511          </div>
27512        </div>
27513        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27514        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27515        <div class="nav-dropdown">
27516          <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>
27517          <div class="nav-dropdown-menu">
27518            <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>
27519          </div>
27520        </div>
27521        <div class="server-status-wrap" id="server-status-wrap">
27522          <div class="nav-pill server-online-pill" id="server-status-pill">
27523            <span class="status-dot" id="status-dot"></span>
27524            <span id="server-status-label">Server</span>
27525            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27526          </div>
27527          <div class="server-status-tip">
27528            OxideSLOC is running — accessible on your network.
27529            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27530          </div>
27531        </div>
27532        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27533          <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>
27534        </button>
27535        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27536          <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>
27537          <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>
27538        </button>
27539      </div>
27540    </div>
27541  </div>
27542
27543  <div class="page">
27544    <div class="watched-bar">
27545      <div class="watched-bar-left">
27546        <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>
27547        <span class="watched-label">Watched Folders</span>
27548        <div class="watched-chips">
27549          {% if server_mode %}
27550          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27551          {% else %}
27552          {% for dir in watched_dirs %}
27553          <span class="watched-chip">
27554            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27555            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27556              <input type="hidden" name="folder_path" value="{{ dir }}">
27557              <input type="hidden" name="redirect_to" value="/compare-scans">
27558              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27559            </form>
27560          </span>
27561          {% endfor %}
27562          {% if watched_dirs.is_empty() %}
27563          <span class="watched-none">No folders watched — click Choose to add one</span>
27564          {% endif %}
27565          {% endif %}
27566        </div>
27567      </div>
27568      {% if !server_mode %}
27569      <div class="watched-bar-right">
27570        <button type="button" class="btn" id="add-watched-btn">
27571          <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>
27572          Choose
27573        </button>
27574        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27575          <input type="hidden" name="redirect_to" value="/compare-scans">
27576          <button type="submit" class="btn">&#8635; Refresh</button>
27577        </form>
27578      </div>
27579      {% endif %}
27580    </div>
27581    {% if total_scans > 0 %}
27582    <div class="summary-strip">
27583      <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>
27584      <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>
27585      <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>
27586      <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>
27587    </div>
27588    {% endif %}
27589    <section class="panel">
27590      <div class="panel-header">
27591        <div>
27592          <h1>Compare Scans</h1>
27593          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27594        </div>
27595        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27596          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27597            <button class="btn primary" id="compare-btn" disabled>
27598              <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>
27599              Compare <span class="sel-count" id="sel-count">0</span> Selected
27600            </button>
27601          </div>
27602        </div>
27603      </div>
27604
27605      {% if entries.is_empty() %}
27606      <div class="empty-state">
27607        <strong>No scans yet</strong>
27608        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.
27609      </div>
27610      {% else %}
27611      <div class="filter-row">
27612        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27613        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27614        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27615      </div>
27616      <div class="scope-panel hidden" id="scope-panel">
27617        <div class="scope-panel-label">
27618          <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>
27619          Compare scope — choose what to include
27620        </div>
27621        <div class="scope-options" id="scope-options"></div>
27622      </div>
27623      {% if total_scans > 0 %}
27624      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27625        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27626          <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>
27627          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27628        </div>
27629      </div>
27630      {% endif %}
27631      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27632        <span class="compare-all-label">
27633          <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>
27634          Quick Compare All
27635        </span>
27636      </div>
27637      <div class="table-wrap">
27638        <table id="compare-table">
27639          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27640          <thead>
27641            <tr id="compare-thead">
27642              <th><div class="col-resize-handle"></div></th>
27643              <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>
27644              <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>
27645              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27646              <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>
27647              <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>
27648              <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>
27649              <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>
27650              <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>
27651              <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>
27652              <th>Submodules<div class="col-resize-handle"></div></th>
27653            </tr>
27654          </thead>
27655          <tbody id="compare-tbody">
27656            {% for entry in entries %}
27657            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27658                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27659                data-project="{{ entry.project_label }}"
27660                data-files="{{ entry.files_analyzed }}"
27661                data-code="{{ entry.code_lines }}"
27662                data-comments="{{ entry.comment_lines }}"
27663                data-blank="{{ entry.blank_lines }}"
27664                data-branch="{{ entry.git_branch }}"
27665                data-commit="{{ entry.git_commit }}"
27666                data-submodules="{{ entry.submodule_names_csv }}">
27667              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27668              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27669              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27670              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27671              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27672              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27673              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27674              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27675              <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>
27676              <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>
27677              <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>
27678            </tr>
27679            {% endfor %}
27680          </tbody>
27681        </table>
27682      </div>
27683      <div class="pagination">
27684        <span class="pagination-info" id="pagination-info"></span>
27685        <div class="pagination-btns" id="pagination-btns"></div>
27686        <div class="flex-row">
27687          <span class="per-page-label">Show</span>
27688          <select class="per-page" id="per-page-sel">
27689            <option value="10">10 per page</option>
27690            <option value="25" selected>25 per page</option>
27691            <option value="50">50 per page</option>
27692            <option value="100">100 per page</option>
27693          </select>
27694          <span class="per-page-label" id="page-range-label"></span>
27695        </div>
27696      </div>
27697      {% endif %}
27698    </section>
27699  </div>
27700
27701  <footer class="site-footer">
27702    local code analysis - metrics, history and reports
27703    &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>
27704    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27705    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27706    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27707    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27708  </footer>
27709
27710  <script nonce="{{ csp_nonce }}">
27711    (function () {
27712      // ── Theme ──────────────────────────────────────────────────────────────
27713      var storageKey = 'oxide-sloc-theme';
27714      var body = document.body;
27715      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27716      var toggle = document.getElementById('theme-toggle');
27717      if (toggle) toggle.addEventListener('click', function () {
27718        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27719        body.classList.toggle('dark-theme', next === 'dark');
27720        try { localStorage.setItem(storageKey, next); } catch(e) {}
27721      });
27722
27723      // ── State ─────────────────────────────────────────────────────────────
27724      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27725      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27726      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27727      window._allCompareRows = allRows;
27728
27729      // ── Stat chips ────────────────────────────────────────────────────────
27730      (function() {
27731        var projects = {}, latestTs = '', latestRow = null;
27732        allRows.forEach(function(r) {
27733          var p = r.dataset.project || ''; if (p) projects[p] = true;
27734          var ts = r.dataset.timestamp || '';
27735          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27736        });
27737        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();}
27738        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>':'');}
27739        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27740        if (latestRow) {
27741          setChipVal('agg-code', latestRow.dataset.code);
27742          setChipVal('agg-files', latestRow.dataset.files);
27743        }
27744        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(); });
27745      })();
27746
27747      // ── Branch filter population ──────────────────────────────────────────
27748      (function() {
27749        var branches = {};
27750        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27751        var sel = document.getElementById('branch-filter');
27752        if (sel) Object.keys(branches).sort().forEach(function(b) {
27753          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27754        });
27755      })();
27756
27757      // ── Filter ────────────────────────────────────────────────────────────
27758      function getFilteredRows() {
27759        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27760        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27761        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27762          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27763          if (branch && (r.dataset.branch || '') !== branch) return false;
27764          return true;
27765        });
27766      }
27767
27768      // ── Pagination ────────────────────────────────────────────────────────
27769      function renderPage() {
27770        var filtered = getFilteredRows();
27771        var total = filtered.length;
27772        var totalPages = Math.max(1, Math.ceil(total / perPage));
27773        currentPage = Math.min(currentPage, totalPages);
27774        var start = (currentPage - 1) * perPage;
27775        var end = Math.min(start + perPage, total);
27776        var shown = {};
27777        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27778        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27779          r.style.display = shown[r.dataset.run] ? '' : 'none';
27780        });
27781        var rl = document.getElementById('page-range-label');
27782        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27783        var info = document.getElementById('pagination-info');
27784        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27785        var btns = document.getElementById('pagination-btns');
27786        if (!btns) return;
27787        btns.innerHTML = '';
27788        function makeBtn(lbl, pg, active, disabled) {
27789          var b = document.createElement('button');
27790          b.className = 'pg-btn' + (active ? ' active' : '');
27791          b.textContent = lbl; b.disabled = disabled;
27792          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27793          return b;
27794        }
27795        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27796        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27797        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27798        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27799      }
27800
27801      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27802      window.applyFilters = function() { currentPage = 1; renderPage(); };
27803
27804      // ── Sorting ───────────────────────────────────────────────────────────
27805      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27806      function doSort(col, type, order) {
27807        var tbody = document.getElementById('compare-tbody');
27808        if (!tbody) return;
27809        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27810        rows.sort(function(a, b) {
27811          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27812          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27813          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27814          return va < vb ? 1 : va > vb ? -1 : 0;
27815        });
27816        rows.forEach(function(r) { tbody.appendChild(r); });
27817        currentPage = 1; renderPage();
27818      }
27819      sortHeaders.forEach(function(th) {
27820        th.addEventListener('click', function(e) {
27821          if (e.target.classList.contains('col-resize-handle')) return;
27822          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27823          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27824          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27825          th.classList.add('sort-' + sortOrder);
27826          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27827          doSort(col, type, sortOrder);
27828        });
27829      });
27830
27831      // Apply default sort (timestamp desc) on initial load
27832      (function() {
27833        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27834        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27835      })();
27836
27837      // ── Column resize ─────────────────────────────────────────────────────
27838      (function() {
27839        var table = document.getElementById('compare-table');
27840        if (!table) return;
27841        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27842        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27843        ths.forEach(function(th, i) {
27844          var handle = th.querySelector('.col-resize-handle');
27845          if (!handle || !cols[i]) return;
27846          var startX, startW;
27847          handle.addEventListener('mousedown', function(e) {
27848            e.stopPropagation(); e.preventDefault();
27849            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27850            handle.classList.add('dragging');
27851            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27852            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27853            document.addEventListener('mousemove', onMove);
27854            document.addEventListener('mouseup', onUp);
27855          });
27856        });
27857      })();
27858
27859      // ── Full-commit hover tooltip ─────────────────────────────────────────
27860      // The commit chips live inside an overflow:auto table wrapper, which would
27861      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27862      // (escaping the scroll container) and follow the cursor. Event delegation
27863      // keeps it working after pagination/sorting re-renders the rows.
27864      (function() {
27865        var tip = document.createElement('div');
27866        tip.className = 'commit-tip';
27867        tip.setAttribute('role', 'tooltip');
27868        document.body.appendChild(tip);
27869        var shown = false;
27870        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27871        function place(e) {
27872          var pad = 14, r = tip.getBoundingClientRect();
27873          var x = e.clientX + pad, y = e.clientY + pad;
27874          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27875          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27876          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27877        }
27878        function hide() { tip.style.display = 'none'; shown = false; }
27879        document.addEventListener('mouseover', function(e) {
27880          var chip = chipFrom(e.target);
27881          if (!chip) return;
27882          var full = chip.getAttribute('data-full-commit');
27883          if (!full) return;
27884          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27885        });
27886        document.addEventListener('mousemove', function(e) {
27887          if (!shown) return;
27888          if (chipFrom(e.target)) place(e); else hide();
27889        });
27890        document.addEventListener('mouseout', function(e) {
27891          if (chipFrom(e.target)) hide();
27892        });
27893      })();
27894
27895      // ── Reset view ────────────────────────────────────────────────────────
27896      window.resetView = function() {
27897        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27898        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27899        sortCol = null; sortOrder = 'asc';
27900        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27901        var tbody = document.getElementById('compare-tbody');
27902        if (tbody) {
27903          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27904          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27905          rows.forEach(function(r) { tbody.appendChild(r); });
27906        }
27907        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27908        var table = document.getElementById('compare-table');
27909        currentPage = 1; renderPage();
27910        currentPage = 1; renderPage();
27911      };
27912
27913      renderPage();
27914      buildCompareAllBar();
27915
27916      // ── Row selection state ───────────────────────────────────────────────
27917      var selected = [];
27918      var lockedProject = null; // project label of first selected scan
27919
27920      function updateCompareBtn() {
27921        var btn = document.getElementById('compare-btn');
27922        var cnt = document.getElementById('sel-count');
27923        if (!btn) return;
27924        btn.disabled = selected.length < 2;
27925        if (cnt) cnt.textContent = selected.length;
27926      }
27927
27928      function applyProjectLock() {
27929        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27930        allRows.forEach(function(r) {
27931          if (lockedProject === null) {
27932            r.classList.remove('row-locked');
27933          } else {
27934            var proj = r.dataset.project || '';
27935            if (proj !== lockedProject) {
27936              r.classList.add('row-locked');
27937            } else {
27938              r.classList.remove('row-locked');
27939            }
27940          }
27941        });
27942      }
27943
27944      function toggleRow(row) {
27945        if (row.classList.contains('row-locked')) return;
27946        var vid = row.dataset.vid || row.dataset.run;
27947        var idx = selected.indexOf(vid);
27948        if (idx >= 0) {
27949          selected.splice(idx, 1);
27950          row.classList.remove('selected');
27951          var b = document.getElementById('badge-' + vid);
27952          if (b) b.textContent = '';
27953          // Release project lock if nothing selected
27954          if (selected.length === 0) lockedProject = null;
27955        } else {
27956          // Set project lock on first selection
27957          if (selected.length === 0) lockedProject = row.dataset.project || null;
27958          selected.push(vid);
27959          row.classList.add('selected');
27960        }
27961        selected.forEach(function(v, i) {
27962          var b = document.getElementById('badge-' + v);
27963          if (b) b.textContent = i + 1;
27964        });
27965        applyProjectLock();
27966        updateCompareBtn();
27967        buildScopePanel();
27968      }
27969
27970      // ── Compare-All bar ───────────────────────────────────────────────────
27971      function buildCompareAllBar() {
27972        var bar = document.getElementById('compare-all-bar');
27973        if (!bar) return;
27974        // Group all rows by project label.
27975        var groups = {};
27976        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27977        // Use all rows from the source data (not just visible).
27978        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27979        // We need ALL rows across all pages, not just the rendered ones.
27980        // Use the underlying allRows array that the pagination JS also uses.
27981        var sourceRows = window._allCompareRows || allRowsAll;
27982        sourceRows.forEach(function(r) {
27983          var proj = r.dataset.project || '';
27984          var vid = r.dataset.vid || r.dataset.run || '';
27985          if (!proj || !vid) return;
27986          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
27987          groups[proj].ids.push(vid);
27988          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
27989        });
27990        // Build buttons for each project with >= 2 scans.
27991        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
27992        if (!keys.length) { bar.style.display = 'none'; return; }
27993        bar.style.display = 'flex';
27994        // Remove old buttons (keep label).
27995        var oldBtns = bar.querySelectorAll('.compare-all-btn');
27996        oldBtns.forEach(function(b) { b.remove(); });
27997        keys.sort();
27998        keys.forEach(function(proj) {
27999          var g = groups[proj];
28000          var btn = document.createElement('button');
28001          btn.className = 'compare-all-btn';
28002          btn.type = 'button';
28003          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28004          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28005          btn.addEventListener('click', function() {
28006            // Sort ids by timestamp (ascending).
28007            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28008            pairs.sort(function(a, b) { return a.ts - b.ts; });
28009            var sorted = pairs.map(function(p) { return p.id; });
28010            if (sorted.length === 2) {
28011              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28012            } else {
28013              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28014            }
28015          });
28016          bar.appendChild(btn);
28017        });
28018      }
28019
28020      // ── Scope panel ───────────────────────────────────────────────────────
28021      var selectedScope = 'all';
28022
28023      function buildScopePanel() {
28024        var panel = document.getElementById('scope-panel');
28025        var opts = document.getElementById('scope-options');
28026        if (!panel || !opts) return;
28027        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28028
28029        // Collect union of submodules from all selected rows.
28030        var allSubs = {};
28031        selected.forEach(function(vid) {
28032          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28033          if (!row) return;
28034          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28035        });
28036        var subList = Object.keys(allSubs).sort();
28037        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28038
28039        panel.classList.remove('hidden');
28040        opts.innerHTML = '';
28041
28042        function makeOption(value, label, title) {
28043          var div = document.createElement('div');
28044          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28045          div.dataset.scopeValue = value;
28046          if (title) div.title = title;
28047          var radio = document.createElement('span');
28048          radio.className = 'scope-option-radio';
28049          var lbl = document.createElement('span');
28050          lbl.textContent = label;
28051          div.appendChild(radio);
28052          div.appendChild(lbl);
28053          div.addEventListener('click', function() {
28054            selectedScope = value;
28055            opts.querySelectorAll('.scope-option').forEach(function(o) {
28056              o.classList.toggle('selected', o.dataset.scopeValue === value);
28057            });
28058          });
28059          return div;
28060        }
28061
28062        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28063        var sep = document.createElement('span');
28064        sep.className = 'scope-option-sep';
28065        opts.appendChild(sep);
28066        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28067        subList.forEach(function(s) {
28068          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28069        });
28070      }
28071
28072      function doCompare() {
28073        if (selected.length < 2) return;
28074        if (selected.length === 2) {
28075          // Two-scan delta (existing flow with scope support).
28076          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28077          if (selectedScope === 'super') url += '&scope=super';
28078          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28079          window.location.href = url;
28080        } else {
28081          // Multi-scan timeline (N >= 3) — pass scope params too.
28082          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28083          if (selectedScope === 'super') url += '&scope=super';
28084          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28085          window.location.href = url;
28086        }
28087      }
28088
28089      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28090      var cbtn = document.getElementById('compare-btn');
28091      if (cbtn) cbtn.addEventListener('click', doCompare);
28092      var pfEl = document.getElementById('project-filter');
28093      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28094      var bfEl = document.getElementById('branch-filter');
28095      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28096      var rvBtn = document.getElementById('reset-view-btn');
28097      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28098      var ppSel = document.getElementById('per-page-sel');
28099      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28100
28101      var cmpTbody = document.getElementById('compare-tbody');
28102      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28103        var row = e.target.closest('.compare-row');
28104        if (row) toggleRow(row);
28105      });
28106
28107      (function randomizeWatermarks() {
28108        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28109        if (!wms.length) return;
28110        var placed = [];
28111        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;}
28112        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];}
28113        var half=Math.floor(wms.length/2);
28114        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;});
28115      })();
28116
28117      (function spawnCodeParticles() {
28118        var container = document.getElementById('code-particles');
28119        if (!container) return;
28120        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'];
28121        for (var i = 0; i < 38; i++) {
28122          (function(idx) {
28123            var el = document.createElement('span');
28124            el.className = 'code-particle';
28125            el.textContent = snippets[idx % snippets.length];
28126            var left = Math.random() * 94 + 2;
28127            var top = Math.random() * 88 + 6;
28128            var dur = (Math.random() * 10 + 9).toFixed(1);
28129            var delay = (Math.random() * 18).toFixed(1);
28130            var rot = (Math.random() * 26 - 13).toFixed(1);
28131            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28132            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';
28133            container.appendChild(el);
28134          })(i);
28135        }
28136      })();
28137
28138      // ── Watched folder picker ─────────────────────────────────────────────
28139      (function() {
28140        var btn = document.getElementById('add-watched-btn');
28141        if (!btn) return;
28142        btn.addEventListener('click', function() {
28143          fetch('/pick-directory?kind=reports')
28144            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28145            .then(function(data) {
28146              if (!data.cancelled && data.selected_path) {
28147                var form = document.createElement('form');
28148                form.method = 'POST';
28149                form.action = '/watched-dirs/add';
28150                var ri = document.createElement('input');
28151                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28152                var fi = document.createElement('input');
28153                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28154                form.appendChild(ri); form.appendChild(fi);
28155                document.body.appendChild(form);
28156                form.submit();
28157              }
28158            })
28159            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28160        });
28161      })();
28162
28163      // ── Submodule chip truncation ─────────────────────────────────────────
28164      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28165        var chips = cell.querySelectorAll('.submod-chip');
28166        var MAX = 4;
28167        if (chips.length <= MAX) return;
28168        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28169        var badge = document.createElement('span');
28170        badge.className = 'submod-overflow-badge';
28171        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28172        badge.textContent = '+' + (chips.length - MAX) + ' more';
28173        cell.appendChild(badge);
28174        cell.style.maxHeight = 'none';
28175      });
28176    })();
28177  </script>
28178  <script nonce="{{ csp_nonce }}">
28179  (function(){
28180    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'}];
28181    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);});}
28182    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28183    function init(){
28184      var btn=document.getElementById('settings-btn');if(!btn)return;
28185      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28186      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>';
28187      document.body.appendChild(m);
28188      var g=document.getElementById('scheme-grid');
28189      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);});
28190      var cl=document.getElementById('settings-close');
28191      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);
28192      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');});
28193      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28194      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28195    }
28196    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28197  }());
28198  </script>
28199  <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]';
28200  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;}
28201  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>
28202</body>
28203</html>
28204"##,
28205    ext = "html"
28206)]
28207struct CompareSelectTemplate {
28208    version: &'static str,
28209    entries: Vec<HistoryEntryRow>,
28210    total_scans: usize,
28211    watched_dirs: Vec<String>,
28212    csp_nonce: String,
28213    server_mode: bool,
28214}
28215
28216// ── CompareTemplate ────────────────────────────────────────────────────────────
28217
28218#[derive(Template)]
28219#[template(
28220    source = r##"
28221<!doctype html>
28222<html lang="en">
28223<head>
28224  <meta charset="utf-8">
28225  <meta name="viewport" content="width=device-width, initial-scale=1">
28226  <title>OxideSLOC | Scan Delta</title>
28227  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28228  <style nonce="{{ csp_nonce }}">
28229    :root {
28230      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28231      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28232      --nav:#283790; --nav-2:#013e6b;
28233      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28234      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28235      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28236    }
28237    body.dark-theme {
28238      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28239      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28240    }
28241    *{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;}
28242    .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);}
28243    .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;}
28244    .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));}
28245    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28246    .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;}
28247    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28248    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28249    @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; } }
28250    .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;}
28251    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28252    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28253    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28254    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28255    .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;}
28256    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28257    .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);}
28258    .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;}
28259    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28260    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28261    .settings-modal-body{padding:14px 16px 16px;}
28262    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28263    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28264    .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;}
28265    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28266    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28267    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28268    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28269    .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;}
28270    .tz-select:focus{border-color:var(--oxide);}
28271    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28272    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28273    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28274    .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;}
28275    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28276    .hero-body{display:block;}
28277    .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;}
28278    .btn-back:hover{background:var(--line);}
28279    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28280    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28281    .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;}
28282    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28283    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;}
28284    .muted{color:var(--muted);font-size:14px;}
28285    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28286    .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;}
28287    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28288    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28289    .vpill-arrow{font-size:20px;color:var(--muted);}
28290    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28291    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28292    .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;}
28293    .delta-card.delta-card-wide{padding:22px 24px;}
28294    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28295    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28296    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28297    .delta-card-from{font-size:15px;color:var(--muted);}
28298    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28299    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28300    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28301    .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%;}
28302    .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;}
28303    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28304    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28305    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28306    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28307    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28308    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28309    .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;}
28310    .meta-card-commit:hover{color:var(--oxide);}
28311    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28312    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28313    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28314    .meta-value{color:var(--text);font-size:13px;}
28315    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28316    .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;}
28317    .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);}
28318    .delta-card:hover .dc-tip{display:block;}
28319    .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;}
28320    .export-btn:hover{background:var(--line);}
28321    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28322    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28323    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28324    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28325    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28326    .delta-card-change.zero{color:var(--muted);background:transparent;}
28327    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28328    .delta-card-pct.pos{color:var(--pos);}
28329    .delta-card-pct.neg{color:var(--neg);}
28330    .delta-card-pct.zero{color:var(--muted);}
28331    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28332    .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;}
28333    .insight-card.insight-flag{border-color:var(--oxide);}
28334    .insight-card:hover .dc-tip{display:block;}
28335    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28336    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28337    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28338    .insight-label.flag{color:var(--oxide);}
28339    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28340    .insight-val.pos{color:var(--pos);}
28341    .insight-val.neg{color:var(--neg);}
28342    .insight-val.high{color:#c0392a;}
28343    .insight-val.med{color:#926000;}
28344    .insight-val.low{color:var(--pos);}
28345    body.dark-theme .insight-val.high{color:#ff6b6b;}
28346    body.dark-theme .insight-val.med{color:#f0c060;}
28347    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28348    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28349    .fc-row{display:flex;align-items:center;gap:8px;}
28350    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28351    .fc-label{color:var(--muted);}
28352    .fc-modified .fc-count{color:#926000;}
28353    .fc-added .fc-count{color:var(--pos);}
28354    .fc-removed .fc-count{color:var(--neg);}
28355    .fc-unchanged .fc-count{color:var(--muted);}
28356    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28357    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28358    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28359    .chip.modified{background:#fff2d8;color:#926000;}
28360    .chip.added{background:#e8f5ed;color:#1a8f47;}
28361    .chip.removed{background:#fdeaea;color:#b33b3b;}
28362    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28363    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28364    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28365    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28366    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28367    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28368    .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;}
28369    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28370    .tab-btn:hover:not(.active){background:var(--line);}
28371    .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;}
28372    .btn-reset:hover{background:var(--line);}
28373    .table-wrap{width:100%;overflow-x:auto;}
28374    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28375    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);}
28376    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28377    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28378    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28379    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28380    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28381    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28382    tr:last-child td{border-bottom:none;}
28383    tr:hover td{background:var(--surface-2);}
28384    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28385    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28386    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28387    /* Fixed layout: column widths come from the colgroup, not from scanning every
28388       row. With auto layout a large file matrix forces the browser to re-measure
28389       all cells on each reflow, which freezes the page during sort/resize. */
28390    #delta-table{table-layout:fixed;}
28391    #delta-table col:nth-child(1){width:32%;}
28392    #delta-table col:nth-child(2){width:11%;}
28393    #delta-table col:nth-child(3){width:11%;}
28394    #delta-table col:nth-child(4){width:16%;}
28395    #delta-table col:nth-child(5){width:10%;}
28396    #delta-table col:nth-child(6){width:10%;}
28397    #delta-table col:nth-child(7){width:10%;}
28398    tr.row-added td{background:rgba(26,143,71,0.04);}
28399    tr.row-removed td{background:rgba(179,59,59,0.06);}
28400    tr.row-modified td{background:rgba(146,96,0,0.04);}
28401    tr.row-unchanged td{color:var(--muted);}
28402    tr.row-unchanged .status-badge{opacity:.65;}
28403    .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;}
28404    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28405    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28406    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28407    .status-badge.modified{background:#fff2d8;color:#926000;}
28408    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28409    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28410    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28411    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28412    .delta-val{font-weight:700;}
28413    .delta-val.pos{color:var(--pos);}
28414    .delta-val.neg{color:var(--neg);}
28415    .delta-val.zero{color:var(--muted);}
28416    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28417    .from-to strong{color:var(--text);font-weight:700;}
28418    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28419    .from-to .ft-absent{color:var(--muted);font-weight:600;}
28420    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28421    .site-footer a{color:var(--muted);}
28422    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;}
28423    body.pdf-mode{background:#fff!important;}
28424    body.pdf-mode .page{padding:4px 6px 4px!important;}
28425    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28426    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28427    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28428    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28429    .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;}
28430    .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;}
28431    .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;}
28432    @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));}}
28433    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28434    .path-link:hover{color:var(--oxide-2);}
28435    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28436    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28437    a.vpill-id:hover{color:var(--oxide);}
28438    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28439    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28440    .pagination-info{font-size:13px;color:var(--muted);}
28441    .pagination-btns{display:flex;gap:6px;}
28442    .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;}
28443    .pg-btn:hover:not(:disabled){background:var(--line);}
28444    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28445    .pg-btn:disabled{opacity:.35;cursor:default;}
28446    .per-page-label{font-size:13px;color:var(--muted);}
28447    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;}
28448    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28449    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28450    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28451    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28452    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28453    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28454    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28455    .tab-btn.tab-unchanged{color:var(--muted);}
28456    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28457    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28458    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28459    .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;}
28460    .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;}
28461    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28462    .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;}
28463    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28464    .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;}
28465    .submod-scope-btn:hover{background:var(--line);}
28466    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28467    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28468    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28469    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28470    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28471    body.dark-theme .ic-card{background:var(--surface-2);}
28472    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28473    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28474    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28475    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28476    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28477    .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);}
28478    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28479    .ic-card-h2-row .ic-card-h2{margin:0;}
28480    .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;}
28481    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28482    .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;}
28483    .ic-svg-modal-ov.open{display:flex;}
28484    .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);}
28485    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28486    .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);}
28487    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28488    .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;}
28489    .ic-svg-modal-close:hover{background:var(--line);}
28490    .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;}
28491    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28492    .chart-metric-btn:hover:not(.active){background:var(--line);}
28493    .chart-wrap{width:100%;overflow-x:auto;}
28494    #cmp-tl-svg{display:block;width:100%;}
28495    .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);}
28496    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28497    #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;}
28498  </style>
28499</head>
28500<body>
28501  {{ loading_overlay|safe }}
28502  <div class="background-watermarks" aria-hidden="true">
28503    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28504    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28505    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28506    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28507    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28508    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28509  </div>
28510  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28511  <div class="top-nav">
28512    <div class="top-nav-inner">
28513      <a class="brand" href="/">
28514        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28515        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28516      </a>
28517      <div class="nav-right">
28518        <a class="nav-pill" href="/">Home</a>
28519        <div class="nav-dropdown">
28520          <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>
28521          <div class="nav-dropdown-menu">
28522            <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>
28523          </div>
28524        </div>
28525        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28526        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28527        <div class="nav-dropdown">
28528          <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>
28529          <div class="nav-dropdown-menu">
28530            <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>
28531          </div>
28532        </div>
28533        <div class="server-status-wrap" id="server-status-wrap">
28534          <div class="nav-pill server-online-pill" id="server-status-pill">
28535            <span class="status-dot" id="status-dot"></span>
28536            <span id="server-status-label">Server</span>
28537            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28538          </div>
28539          <div class="server-status-tip">
28540            OxideSLOC is running — accessible on your network.
28541            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28542          </div>
28543        </div>
28544        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28545          <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>
28546        </button>
28547        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28548          <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>
28549          <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>
28550        </button>
28551      </div>
28552    </div>
28553  </div>
28554
28555  <div class="page">
28556    <section class="hero">
28557      <div class="hero-header">
28558        <div>
28559          <h1 class="delta-title">Scan Delta</h1>
28560          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28561          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28562            {% if let Some(sub) = active_submodule %}
28563            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28564            {% else if super_scope_active %}
28565            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28566            {% else %}
28567            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28568            {% endif %}
28569            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28570          </div>
28571        </div>
28572        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28573          <a class="btn-back" href="/compare-scans">
28574            <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>
28575            Compare Scans
28576          </a>
28577          <div class="export-group" style="margin-top:12px;">
28578            <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>
28579            <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>
28580          </div>
28581        </div>
28582      </div>
28583      {% if has_any_submodule_data %}
28584      <div class="submod-scope-bar">
28585        <span class="submod-scope-label">
28586          <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>
28587          Scope:
28588        </span>
28589        <div class="submod-scope-divider"></div>
28590        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28591           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
28592           title="All files — super-repo and all submodules combined">Full scan</a>
28593        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28594           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
28595           title="Only files that are not part of any submodule">Super-repo only</a>
28596        {% for sub in submodule_options %}
28597        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28598           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
28599           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28600        {% endfor %}
28601      </div>
28602      {% endif %}
28603      <div class="hero-body">
28604      <div class="meta-strip">
28605        <div class="delta-card delta-card-meta">
28606          <div class="meta-card-header">
28607            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28608            <div class="meta-card-project-col">
28609              <div class="meta-card-project">{{ project_name }}</div>
28610              {% if has_any_submodule_data %}
28611              {% if let Some(sub) = active_submodule %}
28612              <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>
28613              {% else if super_scope_active %}
28614              <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>
28615              {% else %}
28616              <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>
28617              {% endif %}
28618              {% endif %}
28619            </div>
28620          </div>
28621          {% if !baseline_git_commit.is_empty() %}
28622          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28623          {% else %}
28624          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28625          {% endif %}
28626          <div class="meta-card-rows">
28627            <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>
28628            <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>
28629            <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>
28630            <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>
28631            {% if let Some(tags) = baseline_git_tags %}
28632            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28633            {% endif %}
28634          </div>
28635        </div>
28636        <div class="delta-card delta-card-meta">
28637          <div class="meta-card-header">
28638            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28639            <div class="meta-card-project-col">
28640              <div class="meta-card-project">{{ project_name }}</div>
28641              {% if has_any_submodule_data %}
28642              {% if let Some(sub) = active_submodule %}
28643              <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>
28644              {% else if super_scope_active %}
28645              <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>
28646              {% else %}
28647              <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>
28648              {% endif %}
28649              {% endif %}
28650            </div>
28651          </div>
28652          {% if !current_git_commit.is_empty() %}
28653          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28654          {% else %}
28655          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28656          {% endif %}
28657          <div class="meta-card-rows">
28658            <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>
28659            <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>
28660            <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>
28661            <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>
28662            {% if let Some(tags) = current_git_tags %}
28663            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28664            {% endif %}
28665          </div>
28666        </div>
28667      </div>
28668      <div class="delta-strip">
28669        <div class="delta-card">
28670          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28671          <div class="delta-card-label">Code lines</div>
28672          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28673          <div class="delta-card-to">{{ current_code_fmt }}</div>
28674          {% 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>
28675          {% 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>
28676          {% else %}<div class="delta-card-pct zero">±0%</div>
28677          {% endif %}
28678        </div>
28679        <div class="delta-card">
28680          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28681          <div class="delta-card-label">Files analyzed</div>
28682          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28683          <div class="delta-card-to">{{ current_files_fmt }}</div>
28684          {% 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>
28685          {% 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>
28686          {% else %}<div class="delta-card-pct zero">±0%</div>
28687          {% endif %}
28688        </div>
28689        <div class="delta-card">
28690          <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>
28691          <div class="delta-card-label">Comment lines</div>
28692          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28693          <div class="delta-card-to">{{ current_comments_fmt }}</div>
28694          {% 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>
28695          {% 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>
28696          {% else %}<div class="delta-card-pct zero">±0%</div>
28697          {% endif %}
28698        </div>
28699        {{ coverage_delta_card|safe }}
28700        <div class="delta-card delta-card-wide">
28701          <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>
28702          <div class="delta-card-label">File changes</div>
28703          <div class="file-changes-grid">
28704            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28705            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28706            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28707            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28708          </div>
28709        </div>
28710      </div>
28711      <div class="insights-panel">
28712        <div class="insight-card">
28713          <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>
28714          <div class="insight-label">Lines Added</div>
28715          <div class="insight-val pos">+{{ code_lines_added }}</div>
28716          <div class="insight-sub">New or grown source lines</div>
28717        </div>
28718        <div class="insight-card">
28719          <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>
28720          <div class="insight-label">Lines Removed</div>
28721          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
28722          <div class="insight-sub">Deleted or shrunk source lines</div>
28723        </div>
28724        <div class="insight-card">
28725          <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>
28726          <div class="insight-label">Churn Rate</div>
28727          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28728          <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>
28729        </div>
28730        {% if scope_flag %}
28731        <div class="insight-card insight-flag">
28732          <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>
28733          <div class="insight-label flag">Scope Signal</div>
28734          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28735          <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>
28736        </div>
28737        {% endif %}
28738      </div>
28739      </div>
28740    </section>
28741
28742    <section class="panel" id="inline-charts-section">
28743      <div class="panel-title">Scan Delta Charts</div>
28744      <div class="ic-grid">
28745        <div class="ic-card" style="grid-column:span 2">
28746          <div class="ic-card-h2-row">
28747            <span class="ic-card-h2">Timeline</span>
28748            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28749              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28750              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28751              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28752              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28753              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28754            </div>
28755            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
28756          </div>
28757          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28758        </div>
28759        <div class="ic-card">
28760          <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>
28761          <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>
28762          <div id="ic-c1"></div>
28763        </div>
28764        <div class="ic-card" id="ic-lang-card">
28765          <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>
28766          <div id="ic-c3"></div>
28767        </div>
28768        <div class="ic-card">
28769          <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>
28770          <div id="ic-c2"></div>
28771        </div>
28772        <div class="ic-card">
28773          <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>
28774          <div id="ic-c4"></div>
28775        </div>
28776      </div>
28777      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28778        <div class="ic-svg-modal">
28779          <div class="ic-svg-modal-hdr">
28780            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28781            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
28782          </div>
28783          <div id="ic-svg-modal-body"></div>
28784        </div>
28785      </div>
28786    </section>
28787
28788    <section class="panel">
28789      <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>
28790      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28791        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28792          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28793          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28794          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28795          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28796          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28797        </div>
28798        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28799          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
28800          <div class="export-group">
28801            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
28802            <button type="button" class="export-btn" id="delta-csv-btn">
28803              <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>
28804              CSV
28805            </button>
28806            <button type="button" class="export-btn" id="delta-xls-btn">
28807              <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>
28808              Excel
28809            </button>
28810          </div>
28811        </div>
28812      </div>
28813
28814      <div class="table-wrap">
28815      <table id="delta-table">
28816        <colgroup>
28817          <col>
28818          <col>
28819          <col>
28820          <col>
28821          <col>
28822          <col>
28823          <col>
28824        </colgroup>
28825        <thead>
28826          <tr id="delta-thead">
28827            <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>
28828            <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>
28829            <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>
28830            <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>
28831            <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>
28832            <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>
28833            <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>
28834          </tr>
28835        </thead>
28836        <tbody id="delta-tbody">
28837          {% for row in file_rows %}
28838          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28839              data-path="{{ row.relative_path }}"
28840              data-language="{{ row.language }}"
28841              data-baseline-code="{{ row.baseline_code }}"
28842              data-current-code="{{ row.current_code }}"
28843              data-code-delta="{{ row.code_delta_str }}"
28844              data-comment-delta="{{ row.comment_delta_str }}"
28845              data-total-delta="{{ row.total_delta_str }}"
28846              data-orig-idx="">
28847            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28848            <td class="hide-sm">{{ row.language }}</td>
28849            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28850            <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>
28851            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28852            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28853            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28854          </tr>
28855          {% endfor %}
28856        </tbody>
28857      </table>
28858      </div>
28859      <div class="pagination">
28860        <span class="pagination-info" id="pg-range-label"></span>
28861        <div class="pagination-btns" id="pg-btns"></div>
28862        <div class="flex-row">
28863          <span class="per-page-label">Show</span>
28864          <select class="per-page" id="per-page-sel">
28865            <option value="10">10 per page</option>
28866            <option value="25" selected>25 per page</option>
28867            <option value="50">50 per page</option>
28868            <option value="100">100 per page</option>
28869          </select>
28870        </div>
28871      </div>
28872    </section>
28873  </div>
28874
28875  <div id="ic-tt"></div>
28876
28877  <footer class="site-footer">
28878    local code analysis - metrics, history and reports
28879    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} \u2014 Mode: Local</em>
28880    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28881    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28882    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28883    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28884  </footer>
28885
28886  <script nonce="{{ csp_nonce }}">
28887    (function () {
28888      var storageKey = 'oxide-sloc-theme';
28889      var body = document.body;
28890      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28891      var toggle = document.getElementById('theme-toggle');
28892      if (toggle) toggle.addEventListener('click', function () {
28893        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28894        body.classList.toggle('dark-theme', next === 'dark');
28895        try { localStorage.setItem(storageKey, next); } catch(e) {}
28896      });
28897
28898      (function randomizeWatermarks() {
28899        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28900        if (!wms.length) return;
28901        var placed = [];
28902        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;}
28903        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];}
28904        var half=Math.floor(wms.length/2);
28905        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;});
28906      })();
28907
28908      (function spawnCodeParticles() {
28909        var container = document.getElementById('code-particles');
28910        if (!container) return;
28911        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'];
28912        for (var i = 0; i < 38; i++) {
28913          (function(idx) {
28914            var el = document.createElement('span');
28915            el.className = 'code-particle';
28916            el.textContent = snippets[idx % snippets.length];
28917            var left = Math.random() * 94 + 2;
28918            var top = Math.random() * 88 + 6;
28919            var dur = (Math.random() * 10 + 9).toFixed(1);
28920            var delay = (Math.random() * 18).toFixed(1);
28921            var rot = (Math.random() * 26 - 13).toFixed(1);
28922            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28923            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';
28924            container.appendChild(el);
28925          })(i);
28926        }
28927      })();
28928    })();
28929
28930    var activeStatusFilter = 'all';
28931    var deltaPerPage = 25, deltaCurrPage = 1;
28932
28933    function openFolder(path) {
28934      fetch('/open-path?path=' + encodeURIComponent(path))
28935        .then(function (r) { return r.json(); })
28936        .then(function (d) {
28937          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
28938        })
28939        .catch(function () {});
28940    }
28941
28942    // \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
28943    // The server renders every row once; we lift them into a plain-data array and
28944    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
28945    // filtering run on the array (no DOM churn) and each render rebuilds just one
28946    // page (~25 rows). This keeps every interaction O(page) instead of O(all
28947    // files): a 28k-row table previously re-touched every node on each click
28948    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
28949    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
28950
28951    function parseDeltaNum(str) {
28952      if (!str || str === '\u2014') return 0;
28953      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
28954    }
28955
28956    function captureDelta() {
28957      var tbody = document.getElementById('delta-tbody');
28958      if (!tbody) return;
28959      var rows = tbody.querySelectorAll('.delta-row');
28960      for (var i = 0; i < rows.length; i++) {
28961        var r = rows[i];
28962        DELTA.push({
28963          h: r.innerHTML,
28964          cls: r.className,
28965          path: r.getAttribute('data-path') || '',
28966          lang: r.getAttribute('data-language') || '',
28967          status: r.getAttribute('data-status') || '',
28968          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
28969          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
28970          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
28971          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
28972          td: parseDeltaNum(r.getAttribute('data-total-delta')),
28973          bcs: r.getAttribute('data-baseline-code') || '',
28974          ccs: r.getAttribute('data-current-code') || '',
28975          cds: r.getAttribute('data-code-delta') || '',
28976          cmds: r.getAttribute('data-comment-delta') || '',
28977          tds: r.getAttribute('data-total-delta') || ''
28978        });
28979      }
28980      tbody.innerHTML = '';
28981    }
28982
28983    function applyDeltaQuery() {
28984      var v = (activeStatusFilter === 'all') ? DELTA.slice()
28985        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
28986      if (sortCol) {
28987        var asc = sortOrder === 'asc';
28988        v.sort(function(a, b) {
28989          var va, vb;
28990          if (sortCol === 'path') { va = a.path; vb = b.path; }
28991          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
28992          else if (sortCol === 'status') { va = a.status; vb = b.status; }
28993          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
28994          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
28995          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
28996          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
28997          else { return 0; }
28998          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
28999          return va < vb ? 1 : va > vb ? -1 : 0;
29000        });
29001      }
29002      _deltaView = v;
29003      deltaCurrPage = 1;
29004      renderDeltaPage();
29005    }
29006
29007    function renderDeltaPage() {
29008      var total = _deltaView.length;
29009      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29010      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29011      if (deltaCurrPage < 1) deltaCurrPage = 1;
29012      var start = (deltaCurrPage - 1) * deltaPerPage;
29013      var end = Math.min(start + deltaPerPage, total);
29014      var tbody = document.getElementById('delta-tbody');
29015      if (tbody) {
29016        var html = '';
29017        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29018        tbody.innerHTML = html;
29019      }
29020      var rl = document.getElementById('pg-range-label');
29021      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29022      var btns = document.getElementById('pg-btns');
29023      if (!btns) return;
29024      btns.innerHTML = '';
29025      if (totalPages <= 1) return;
29026      function makeBtn(lbl, pg, active, disabled) {
29027        var b = document.createElement('button');
29028        b.className = 'pg-btn' + (active ? ' active' : '');
29029        b.textContent = lbl; b.disabled = disabled;
29030        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29031        return b;
29032      }
29033      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29034      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29035      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29036      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29037    }
29038
29039    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29040
29041    function filterRows(status, btn) {
29042      activeStatusFilter = status;
29043      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29044        b.classList.remove('active');
29045      });
29046      if (btn) btn.classList.add('active');
29047      applyDeltaQuery();
29048    }
29049
29050    // ── Sorting ──────────────────────────────────────────────────────────────
29051    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29052    sortHeaders.forEach(function(th) {
29053      th.addEventListener('click', function(e) {
29054        if (e.target.classList.contains('col-resize-handle')) return;
29055        var col = th.dataset.sortCol;
29056        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29057        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29058        th.classList.add('sort-' + sortOrder);
29059        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29060        applyDeltaQuery();
29061      });
29062    });
29063
29064    // ── Column resize ─────────────────────────────────────────────────────────
29065    (function() {
29066      var table = document.getElementById('delta-table');
29067      if (!table) return;
29068      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29069      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29070      ths.forEach(function(th, i) {
29071        var handle = th.querySelector('.col-resize-handle');
29072        if (!handle || !cols[i]) return;
29073        handle.addEventListener('mousedown', function(e) {
29074          e.stopPropagation(); e.preventDefault();
29075          // Lock every column to its current rendered px width and size the table
29076          // to the column total. With table-layout:fixed + width:100% the table is
29077          // pinned to the container, so widening one <col> only rebalances the rest
29078          // and the drag looks inert; pinning px widths lets the column actually
29079          // grow while the wrapper (overflow-x:auto) scrolls.
29080          var startTableW = 0;
29081          for (var k = 0; k < ths.length; k++) {
29082            if (!cols[k]) continue;
29083            var w = ths[k].getBoundingClientRect().width;
29084            cols[k].style.width = w + 'px';
29085            startTableW += w;
29086          }
29087          table.style.width = startTableW + 'px';
29088          var startX = e.clientX;
29089          var startW = ths[i].getBoundingClientRect().width;
29090          handle.classList.add('dragging');
29091          function onMove(ev) {
29092            var newW = Math.max(40, startW + ev.clientX - startX);
29093            cols[i].style.width = newW + 'px';
29094            table.style.width = (startTableW + (newW - startW)) + 'px';
29095          }
29096          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29097          document.addEventListener('mousemove', onMove);
29098          document.addEventListener('mouseup', onUp);
29099        });
29100      });
29101    })();
29102
29103    // ── Reset ─────────────────────────────────────────────────────────────────
29104    window.resetDeltaTable = function() {
29105      sortCol = null; sortOrder = 'asc';
29106      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29107      var table = document.getElementById('delta-table');
29108      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29109      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29110      activeStatusFilter = 'all';
29111      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29112      var allBtn = document.querySelector('.tab-btn');
29113      if (allBtn) allBtn.classList.add('active');
29114      applyDeltaQuery();
29115    };
29116
29117    // Compact number formatter (shared by the delta table; charts define their own locally)
29118    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();}
29119    function fmtFull(n){return Number(n).toLocaleString();}
29120
29121    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29122    function fmtFromTo() {
29123      var tbody = document.getElementById('delta-tbody');
29124      if (!tbody) return;
29125      tbody.querySelectorAll('.delta-row').forEach(function(row) {
29126        var status = row.dataset.status || '';
29127        var ft = row.querySelector('.from-to');
29128        if (!ft) return;
29129        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29130        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29131        var strongs = ft.querySelectorAll('strong');
29132        // Apply fmt() to non-absent strong values
29133        strongs.forEach(function(el) {
29134          var n = parseInt(el.textContent, 10);
29135          if (!isNaN(n)) el.textContent = fmtFull(n);
29136        });
29137        // Safety: force dash for genuinely absent sides
29138        if (status === 'added' && bv === 0) {
29139          var bs = ft.querySelector('strong:first-of-type');
29140          if (bs && bs.textContent === '0') {
29141            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29142          }
29143        }
29144        if (status === 'removed' && cv === 0) {
29145          var cs = ft.querySelector('strong:last-of-type');
29146          if (cs && cs.textContent === '0') {
29147            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29148          }
29149        }
29150      });
29151    }
29152    // Initialize: format the server-rendered rows, lift them into the data model
29153    // (which also clears the DOM), then render only the first page.
29154    fmtFromTo();
29155    captureDelta();
29156    applyDeltaQuery();
29157
29158    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29159    (function() {
29160      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29161        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29162      });
29163      var resetBtn = document.getElementById('delta-reset-btn');
29164      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29165      var csvBtn = document.getElementById('delta-csv-btn');
29166      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29167      var xlsBtn = document.getElementById('delta-xls-btn');
29168      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29169      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29170      function sdFetchUri(path) {
29171        return fetch(path).then(function(r){return r.blob();}).then(function(b){
29172          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29173        }).catch(function(){return '';});
29174      }
29175      function sdInlineImgs(html, cb) {
29176        var paths=[], seen={};
29177        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29178        if(!paths.length){cb(html);return;}
29179        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29180          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29181          .catch(function(){cb(html);});
29182      }
29183      function buildFullPageHtml(pdfMode) {
29184        if(pdfMode) document.body.classList.add('pdf-mode');
29185        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29186        renderDeltaPage();
29187        var html = document.documentElement.outerHTML;
29188        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29189        if(pdfMode) document.body.classList.remove('pdf-mode');
29190        return html;
29191      }
29192      var chartsBtn = document.getElementById('delta-charts-btn');
29193      if (chartsBtn) chartsBtn.addEventListener('click', function() {
29194        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29195        sdInlineImgs(buildFullPageHtml(false), function(html) {
29196          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29197          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29198          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29199          btn.disabled=false;btn.innerHTML=orig;
29200        });
29201      });
29202      var pageHtmlBtn = document.getElementById('page-export-html-btn');
29203      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29204        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29205        sdInlineImgs(buildFullPageHtml(false), function(html) {
29206          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29207          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29208          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29209          btn.disabled=false;btn.innerHTML=orig;
29210        });
29211      });
29212      // PDF export — clean document-style report, not a web page screenshot
29213      function buildDeltaPdfHtml() {
29214        var sd=_sd, dr=getDeltaExportRows();
29215        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29216        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+'%';}
29217        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29218        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29219        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29220        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29221        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29222        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29223        function fmtN(n){return Number(n).toLocaleString();}
29224        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29225        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>';}
29226        var lm={};
29227        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;});
29228        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29229        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29230        // The header/footer flow in normal document order (NOT position:fixed).
29231        // A fixed header repeats on every printed page in Chromium and overlaps
29232        // the content beneath it — silently swallowing the first few table rows of
29233        // pages 2+ and clipping the summary cards on page 1. Letting the header
29234        // flow once at the top and relying on the table's <thead> (which Chromium
29235        // repeats per page) keeps every row visible. `.body` keeps a small inset
29236        // so nothing bleeds to the sheet edge.
29237        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29238          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29239          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29240          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29241          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29242          '.ph-brand em{color:#c45c10;font-style:normal;}'+
29243          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29244          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29245          '.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;}'+
29246          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29247          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29248          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29249          '.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;}'+
29250          '.body{padding:12px 18px 0;}'+
29251          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29252          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29253          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29254          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29255          '.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;}'+
29256          '.meta>div{flex:1 1 0;}'+
29257          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29258          '.sec{margin-bottom:10px;}'+
29259          '.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;}'+
29260          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29261          '.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;}'+
29262          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29263          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29264          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29265          '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;}'+
29266          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29267          'tr:nth-child(even) td{background:#faf8f6;}'+
29268          '.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;}'+
29269          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29270          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29271          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29272          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29273          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29274          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29275          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29276          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29277          '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29278          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29279          '.fcc-n{font-size:18px;font-weight:900;}'+
29280          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29281        var fileRows=dchg.map(function(r){
29282          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29283          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29284            '<td style="'+ss+'">'+esc(st)+'</td>'+
29285            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29286            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29287            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29288        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29289        var more='';
29290        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('');
29291        var extraCards='';
29292        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>';}
29293        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>';}
29294        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29295          '<div class="pdf-header">'+
29296          '<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>'+
29297          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29298          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29299          '</div>'+
29300          '<div class="body">'+
29301          '<div class="sec"><p class="sh">Summary Metrics</p>'+
29302          '<div class="msec">'+
29303          '<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>'+
29304          '<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>'+
29305          '<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>'+
29306          '<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>'+
29307          '<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>'+
29308          '<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>'+
29309          extraCards+'</div></div>'+
29310          '<div class="sec"><p class="sh">File Changes</p>'+
29311          '<div class="fcsec">'+
29312          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29313          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29314          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29315          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29316          '</div></div>'+
29317          (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>':'')+
29318          '<div class="sec">'+
29319          '<table><thead>'+
29320          '<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>'+
29321          '<tr><th>File</th><th>Language</th><th>Status</th>'+
29322          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29323          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29324          '</div>'+
29325          '<div class="rfoot">'+
29326          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29327          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29328          '</div>'+
29329          '</body></html>';
29330      }
29331      function doDeltaPdf(btn) {
29332        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29333      }
29334      var pdfBtn = document.getElementById('delta-pdf-btn');
29335      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29336      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29337      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29338      if (location.protocol === 'file:') {
29339        [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'; } });
29340        [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'; } });
29341      }
29342      var ppSel = document.getElementById('per-page-sel');
29343      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29344      var pathLink = document.getElementById('project-path-link');
29345      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29346    })();
29347
29348    // ── Export helpers ────────────────────────────────────────────────────────
29349    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
29350    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29351    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);}
29352    function slocMakeXlsx(fname,sd,dr){
29353      var enc=new TextEncoder();
29354      // CRC-32 table
29355      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;}
29356      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;}
29357      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29358      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29359      // Shared string table
29360      var ss=[],si={};
29361      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29362      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29363      // Worksheet builder — each WS() call gets its own row counter R
29364      function WS(){
29365        var R=0,buf=[];
29366        function cl(c){return String.fromCharCode(65+c);}
29367        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29368          '<v>'+S(v)+'</v></c>';}
29369        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29370          (st?' s="'+st+'"':'')+'>'+
29371          '<v>'+(+v)+'</v></c>';}
29372        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29373        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29374          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29375          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29376          '<sheetFormatPr defaultRowHeight="15"/>'+
29377          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29378        return{sc:sc,nc:nc,row:row,xml:xml};
29379      }
29380      // Language breakdown
29381      var lm={};
29382      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;});
29383      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29384      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29385      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29386      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29387      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29388      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29389      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):'';}
29390      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29391      // Summary sheet
29392      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29393      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29394      r1(s1(0,proj,2));
29395      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29396      r1('');
29397      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29398      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))));
29399      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))));
29400      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))));
29401      r1('');
29402      r1(s1(0,'FILE CHANGES',8));
29403      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29404      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29405      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29406      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29407      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29408      if(langs.length){
29409        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29410        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29411        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)));});
29412      }
29413      r1('');r1(s1(0,'SCAN METADATA',8));
29414      r1(s1(1,_blabel)+s1(2,_clabel));
29415      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29416      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29417      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"/>');
29418      // File Delta sheet
29419      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29420      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));
29421      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)));});
29422      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29423      // Shared strings XML
29424      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29425        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29426        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29427      // XLSX file map
29428      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29429      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>',
29430        '_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>',
29431        '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>',
29432        '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>',
29433        '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>',
29434        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29435      // ZIP packer — STORED (no compression), compatible with all XLSX readers
29436      var zparts=[],zcds=[],zoff=0,znf=0;
29437      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29438       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29439      ].forEach(function(name){
29440        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29441        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]);
29442        var entry=new Uint8Array(lha.length+nb.length+sz);
29443        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29444        zparts.push(entry);
29445        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));
29446        var cde=new Uint8Array(cda.length+nb.length);
29447        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29448        zcds.push(cde);zoff+=entry.length;znf++;
29449      });
29450      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29451      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]);
29452      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29453      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29454      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29455      zout.set(new Uint8Array(ea),zpos);
29456      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29457      var xurl=URL.createObjectURL(xblob);
29458      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29459      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29460      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29461    }
29462    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;');}
29463    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29464    function getExportFilename(ext){return _exportBase+'.'+ext;}
29465
29466    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 }}'};
29467    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;}
29468    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29469    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29470    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29471    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29472    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):'';}
29473    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29474    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)]];}
29475    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29476    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)];});}
29477    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29478    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29479
29480    // ── Chart HTML report ─────────────────────────────────────────────────────
29481    function slocChartReport(fname, sd, dr) {
29482      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29483      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29484      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29485      function fmt(n){return Number(n).toLocaleString();}
29486      function px(n){return Math.round(n);}
29487      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29488      // Language map
29489      var lm={};
29490      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;});
29491      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29492
29493      // Builds onmouse* attrs for interactive tooltip on each SVG element
29494      function barTT(label,val){
29495        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29496      }
29497
29498      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29499      //    match the Language Code Delta column height) ────────────
29500      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'}];
29501      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29502      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29503      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29504      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29505      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"/>';}
29506      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29507      c1mets.forEach(function(m,i){
29508        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29509        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29510        var gMax=Math.max(m.b,m.c)*1.15||1;
29511        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29512        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>';
29513        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))+'/>';
29514        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>';
29515        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))+'/>';
29516        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>';
29517        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>';
29518        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>';
29519      });
29520      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>';
29521      c1+='</svg>';
29522
29523      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29524      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'}];
29525      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29526      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29527      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29528      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29529      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29530      mets.forEach(function(m,i){
29531        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29532        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29533        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29534        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>';
29535        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29536        if(bw>=52){
29537          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>';
29538        }else{
29539          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29540          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>';
29541        }
29542      });
29543      c2+='</svg>';
29544
29545      // ── Chart 3: Language Code Delta ─────────────────────────────────────
29546      var c3='';
29547      if(langs.length){
29548        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29549        var C3W=550,c3LW=124,c3FW=52;
29550        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29551        var L3rH=30,C3H=langs.length*L3rH+20;
29552        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29553        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29554        langs.forEach(function(l,i){
29555          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29556          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29557          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29558          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>';
29559          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':''))+'/>';
29560          if(bw>=48){
29561            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>';
29562          }else{
29563            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29564            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>';
29565          }
29566          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>';
29567        });
29568        c3+='</svg>';
29569      }
29570
29571      // ── Chart 4: File Change Donut — centered pie with legend below
29572      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;});
29573      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29574      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29575      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">';
29576      var ang=-Math.PI/2;
29577      segs.forEach(function(s){
29578        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29579        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29580        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29581        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29582        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29583        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)+'%')+'/>';
29584        ang+=sw;
29585      });
29586      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>';
29587      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>';
29588      segs.forEach(function(s,i){
29589        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29590        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29591        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>';
29592      });
29593      c4+='</svg>';
29594
29595      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29596      var ttJs='var tt=document.getElementById("ox-tt");'+
29597        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29598        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29599        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29600        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29601        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29602        'function oxHT(){tt.style.display="none";}';
29603
29604      // body max-width keeps charts from inflating beyond design dimensions on
29605      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29606      // each chart's height blows up proportionally, breaking the one-page layout.
29607      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;}'+
29608        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29609        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29610        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29611        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29612        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29613        'svg{display:block;}'+
29614        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29615        '#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;}'+
29616        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29617      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29618        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29619        '<div id="ox-tt"><\/div>'+
29620        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
29621        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
29622        '<div class="two-col">'+
29623        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
29624        '<div class="leg">'+
29625        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29626        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29627        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29628        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
29629        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29630        '<\/div>'+
29631        '<div class="two-col">'+
29632        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29633        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29634        '<\/div>'+
29635        '<script>'+ttJs+'<\/script>'+
29636        '<\/body><\/html>';
29637      slocDownload(html, fname, 'text/html;charset=utf-8;');
29638    }
29639    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29640    window.buildDeltaChartsHtml = function() {
29641      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29642      var sd=_sd;
29643      var projEl=document.querySelector('[data-folder]');
29644      var proj=projEl?projEl.getAttribute('data-folder'):'';
29645      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29646      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29647      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29648      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29649      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";}';
29650      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);}';
29651      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29652        '<div id="ox-tt"><\/div>'+
29653        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29654        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29655        '<div class="two-col">'+
29656        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29657        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29658        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29659        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29660        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29661        '<\/div>'+
29662        '<div class="two-col">'+
29663        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29664        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29665        '<\/div>'+
29666        '<script>'+ttJs+'<\/script>'+
29667        '<\/body><\/html>';
29668    };
29669    // ── Inline delta charts ────────────────────────────────────────────────────
29670    var _icTT=document.getElementById('ic-tt');
29671    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29672    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';};
29673    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29674    window.addEventListener('blur',function(){window.icHT();});
29675    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29676    (function(){
29677      // Theme-aware palette — matches the canonical scheme used by /test-metrics
29678      // charts so every page renders bars/text/grid with the same colours and
29679      // adapts to dark mode (see Design section in CLAUDE.md).
29680      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29681      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29682      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29683      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29684      // washed) so the chart reads with the same weight as /test-metrics.
29685      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29686      var FADE=dark?'#524238':'#e6d0bf';
29687      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29688      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29689      function fmt(n){return Number(n).toLocaleString();}
29690      function px(n){return Math.round(n);}
29691      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29692      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29693      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);});}
29694      var dr=getDeltaExportRows(),sd=_sd,lm={};
29695      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;});
29696      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29697      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29698      // the bars are as tall as the (usually taller) Language Code Delta sibling that
29699      // shares the same grid row, instead of sitting short at the top.
29700      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}];
29701      function drawC1(){
29702        var C1W=600,C1H=188;
29703        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29704        if(host&&card&&host.clientWidth>0){
29705          var avW=host.clientWidth;
29706          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29707          var wantH=availPx*C1W/avW;
29708          if(wantH>C1H)C1H=wantH;
29709        }
29710        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29711        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29712        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"/>';}
29713        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29714        c1mets.forEach(function(m,i){
29715          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29716          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29717          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29718          var gMax=Math.max(m.b,m.c)*1.15||1;
29719          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29720          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>';
29721          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"/>';
29722          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>';
29723          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"/>';
29724          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>';
29725          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>';
29726          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>';
29727        });
29728        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>';
29729        c1+='</svg>';
29730        return c1;
29731      }
29732      var c1=drawC1();
29733      // Chart 2: Delta by Metric
29734      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}];
29735      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29736      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;
29737      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29738      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29739      mets.forEach(function(m,i){
29740        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);
29741        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>';
29742        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29743        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>';}
29744        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>';}
29745      });
29746      c2+='</svg>';
29747      // Chart 3: Language Code Delta
29748      var c3='';
29749      if(langs.length){
29750        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29751        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;
29752        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29753        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29754        langs.forEach(function(l,i){
29755          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);
29756          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>';
29757          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"/>';
29758          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>';}
29759          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>';}
29760          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>';
29761        });
29762        c3+='</svg>';
29763      }
29764      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29765      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29766      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;});
29767      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29768      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29769      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);
29770      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;
29771      if(segs.length===1){
29772        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29773        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+'"/>';
29774      } else {
29775        // Give every visible slice a small minimum sweep, taken from the largest
29776        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29777        // arc whose start and end points coincide, so SVG renders nothing (blank).
29778        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29779        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29780        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29781        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29782        segs.forEach(function(s,si){
29783          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29784          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);
29785          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);
29786          var pct=Math.round(s.v/tot*100);
29787          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"/>';
29788          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>';}
29789          ang+=sw;
29790        });
29791      }
29792      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29793      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29794      segs.forEach(function(s,i){
29795        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29796        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29797        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29798        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29799        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29800        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>';
29801        c4+='</g>';
29802      });
29803      c4+='</svg>';
29804      // Inject the fixed-height siblings first so the grid row settles to the (taller)
29805      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29806      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29807      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);}
29808      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29809      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29810      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29811
29812      // Compare Timeline chart (Baseline vs Current, 2 points)
29813      (function() {
29814        var activeCmpMetric='code';
29815        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29816        function renderCmpTL(metric, targetSvg, targetH) {
29817          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29818          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29819          svg.setAttribute('height',H);
29820          var pad={l:62,r:20,t:32,b:72};
29821          var dark=document.body.classList.contains('dark-theme');
29822          var cmpPts=[
29823            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29824            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29825          ];
29826          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29827          var valid=pts.filter(function(v){return v!=null;});
29828          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;}
29829          var minV=0,maxV=Math.max.apply(null,valid);
29830          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29831          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29832          var cx0=pad.l,cx1=pad.l+plotW;
29833          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29834          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29835          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29836          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29837          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29838          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();}
29839          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29840          var parts=[];
29841          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29842          for(var gi=0;gi<5;gi++){
29843            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29844            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29845            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29846          }
29847          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+'"/>');
29848          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"/>');
29849          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29850                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29851          dotPts.forEach(function(pt){
29852            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>');
29853            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"/>');
29854            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>');
29855            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>');
29856          });
29857          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29858          svg.setAttribute('viewBox','0 0 '+W+' '+H);
29859          svg.innerHTML=parts.join('');
29860          // Hover: crosshair + tooltip (matches multi-scan timeline)
29861          var cmpTT=document.getElementById('ic-tt');
29862          svg.onmousemove=function(e){
29863            var rect=svg.getBoundingClientRect();
29864            var scaleX=W/rect.width;
29865            var mouseX=(e.clientX-rect.left)*scaleX;
29866            var nearest=-1,minDist=Infinity;
29867            var cxArr=[cx0,cx1];
29868            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;}}
29869            if(nearest<0)return;
29870            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
29871            var xhair=svg.querySelector('.cmp-xhair');
29872            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
29873            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"/>';
29874            if(!cmpTT)return;
29875            var clbl=cmpPts[nearest].label;
29876            var scanLbl=nearest===0?'Baseline':'Current';
29877            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>';
29878            var bx=rect.left+(nc/W*rect.width)+18;
29879            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
29880            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
29881          };
29882          svg.onmouseleave=function(){
29883            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
29884            if(cmpTT)cmpTT.style.display='none';
29885          };
29886        }
29887        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
29888          btn.addEventListener('click',function(){
29889            activeCmpMetric=this.dataset.cmpMetric;
29890            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
29891            this.classList.add('active');
29892            renderCmpTL(activeCmpMetric);
29893          });
29894        });
29895        var ttgl=document.getElementById('theme-toggle');
29896        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
29897        if(typeof ResizeObserver!=='undefined'){
29898          var cmpSvg=document.getElementById('cmp-tl-svg');
29899          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
29900        }
29901        // Expose the timeline renderer + current metric so the Full View modal can
29902        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
29903        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
29904        window.__sdGetMetric=function(){return activeCmpMetric;};
29905        renderCmpTL(activeCmpMetric);
29906      })();
29907
29908      // HTML legend hover -> highlight matching SVG bars within the SAME card only
29909      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
29910        var metric=leg.getAttribute('data-highlight');
29911        var parentCard=leg.closest('.ic-card');
29912        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
29913        if(!chartEl)return;
29914        leg.addEventListener('mouseenter',function(){
29915          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
29916            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';}
29917            else{x.style.opacity='0.28';}
29918          });
29919        });
29920        leg.addEventListener('mouseleave',function(){
29921          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
29922        });
29923      });
29924
29925      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
29926      (function(){
29927        var ov=document.getElementById('ic-svg-modal-ov');
29928        var body=document.getElementById('ic-svg-modal-body');
29929        var ttl=document.getElementById('ic-svg-modal-title');
29930        var closeBtn=document.getElementById('ic-svg-modal-close');
29931        if(!ov||!body)return;
29932        function close(){
29933          ov.classList.remove('open');body.innerHTML='';
29934          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
29935          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
29936        }
29937        function open(srcId,title){
29938          var src=document.getElementById(srcId);if(!src)return;
29939          if(ttl)ttl.textContent=title||'';
29940          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
29941          // snapshot stretches and loses interactivity. Re-render it live into the modal at
29942          // full size instead — keeps proportions, animation, crosshair, tooltip and the
29943          // metric tabs working exactly like the inline chart.
29944          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
29945            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
29946            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
29947            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('');
29948            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>';
29949            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
29950            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
29951            ov.classList.add('open');
29952            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
29953            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;}
29954            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
29955              b.addEventListener('click',function(){
29956                if(!window.__sdFvTL)return;
29957                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
29958                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
29959                this.classList.add('active');
29960                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
29961              });
29962            });
29963            return;
29964          }
29965          var card=src.closest('.ic-card');
29966          var legHtml='';
29967          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
29968          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
29969          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;}
29970          body.innerHTML=legHtml+inner;
29971          var svg=body.querySelector('svg');
29972          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
29973          addTT(body);
29974          ov.classList.add('open');
29975        }
29976        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
29977          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
29978        });
29979        if(closeBtn)closeBtn.addEventListener('click',close);
29980        ov.addEventListener('click',function(e){if(e.target===ov)close();});
29981        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
29982      })();
29983
29984      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
29985    })();
29986  </script>
29987  {{ toast_assets|safe }}
29988  <script nonce="{{ csp_nonce }}">
29989  (function(){
29990    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'}];
29991    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);});}
29992    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29993    function init(){
29994      var btn=document.getElementById('settings-btn');if(!btn)return;
29995      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29996      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>';
29997      document.body.appendChild(m);
29998      var g=document.getElementById('scheme-grid');
29999      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);});
30000      var cl=document.getElementById('settings-close');
30001      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);
30002      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');});
30003      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30004      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30005    }
30006    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30007  }());
30008  </script>
30009  <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]';
30010  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;}
30011  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>
30012</body>
30013</html>
30014"##,
30015    ext = "html"
30016)]
30017// Template structs need many bool fields to pass Askama rendering flags.
30018#[allow(clippy::struct_excessive_bools)]
30019struct CompareTemplate {
30020    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30021    loading_overlay: String,
30022    version: &'static str,
30023    project_label: String,
30024    baseline_git_commit: String,
30025    current_git_commit: String,
30026    baseline_run_id: String,
30027    current_run_id: String,
30028    baseline_run_id_short: String,
30029    current_run_id_short: String,
30030    baseline_timestamp: String,
30031    baseline_timestamp_utc_ms: i64,
30032    current_timestamp: String,
30033    current_timestamp_utc_ms: i64,
30034    project_path: String,
30035    baseline_code: u64,
30036    current_code: u64,
30037    code_lines_delta_str: String,
30038    code_lines_delta_class: String,
30039    baseline_files: u64,
30040    current_files: u64,
30041    files_analyzed_delta_str: String,
30042    files_analyzed_delta_class: String,
30043    baseline_comments: u64,
30044    current_comments: u64,
30045    comment_lines_delta_str: String,
30046    comment_lines_delta_class: String,
30047    baseline_code_fmt: String,
30048    current_code_fmt: String,
30049    baseline_files_fmt: String,
30050    current_files_fmt: String,
30051    baseline_comments_fmt: String,
30052    current_comments_fmt: String,
30053    code_lines_pct_str: String,
30054    files_analyzed_pct_str: String,
30055    comment_lines_pct_str: String,
30056    code_lines_added: i64,
30057    code_lines_removed: i64,
30058    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30059    new_scope: bool,
30060    churn_rate_str: String,
30061    churn_rate_class: String,
30062    scope_flag: bool,
30063    files_added: usize,
30064    files_removed: usize,
30065    files_modified: usize,
30066    files_unchanged: usize,
30067    file_rows: Vec<CompareFileDeltaRow>,
30068    baseline_git_author: Option<String>,
30069    current_git_author: Option<String>,
30070    baseline_git_branch: String,
30071    current_git_branch: String,
30072    baseline_git_tags: Option<String>,
30073    current_git_tags: Option<String>,
30074    baseline_git_commit_date: Option<String>,
30075    current_git_commit_date: Option<String>,
30076    project_name: String,
30077    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30078    submodule_options: Vec<String>,
30079    /// True when either run has submodule data — controls whether the scope bar is shown.
30080    has_any_submodule_data: bool,
30081    /// The submodule currently being compared, if the `sub` query param was provided.
30082    active_submodule: Option<String>,
30083    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30084    super_scope_active: bool,
30085    csp_nonce: String,
30086    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
30087    toast_assets: String,
30088    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
30089    coverage_delta_card: String,
30090    baseline_test_count: u64,
30091    current_test_count: u64,
30092    baseline_coverage_pct: Option<f64>,
30093    current_coverage_pct: Option<f64>,
30094}
30095
30096// ── LoginTemplate ──────────────────────────────────────────────────────────────
30097
30098#[derive(Template)]
30099#[template(
30100    source = r##"
30101<!doctype html>
30102<html lang="en">
30103<head>
30104  <meta charset="utf-8">
30105  <meta name="viewport" content="width=device-width, initial-scale=1">
30106  <title>OxideSLOC | Sign In</title>
30107  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30108  <style nonce="{{ csp_nonce }}">
30109    :root {
30110      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30111      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30112      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30113      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30114    }
30115    *{box-sizing:border-box;}
30116    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);}
30117    .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);}
30118    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30119    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30120    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30121    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30122    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30123    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30124    .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;}
30125    @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));}}
30126    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30127    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30128    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30129    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30130    .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;}
30131    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30132    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;}
30133    input[type=password]:focus{border-color:var(--oxide);}
30134    .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;}
30135    .btn:hover{opacity:.88;}
30136    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30137    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30138  </style>
30139</head>
30140<body>
30141  <div class="background-watermarks" aria-hidden="true">
30142    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30143    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30144    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30145    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30146    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30147    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30148    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30149  </div>
30150  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30151<nav class="top-nav">
30152  <a class="brand" href="/">
30153    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30154    <span class="brand-title">OxideSLOC</span>
30155  </a>
30156</nav>
30157<main class="page">
30158  <div class="card">
30159    <h1>Sign In</h1>
30160    <p class="subtitle">Enter the API key printed when the server started.</p>
30161    {% if has_error %}
30162    <div class="error">Incorrect API key — please try again.</div>
30163    {% endif %}
30164    <form method="POST" action="/auth/login">
30165      <input type="hidden" name="next" value="{{ next_url|e }}">
30166      <label for="key">API Key</label>
30167      <input id="key" type="password" name="key" autocomplete="current-password"
30168             placeholder="Paste your API key here" autofocus>
30169      <button type="submit" class="btn">Sign In</button>
30170    </form>
30171    <p class="hint">
30172      The API key was printed in the terminal when the server started.<br>
30173      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30174      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30175    </p>
30176  </div>
30177</main>
30178<script nonce="{{ csp_nonce }}">
30179(function() {
30180  (function randomizeWatermarks() {
30181    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30182    if (!wms.length) return;
30183    var placed = [];
30184    function tooClose(top, left) {
30185      for (var i = 0; i < placed.length; i++) {
30186        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30187        if (dt < 16 && dl < 12) return true;
30188      }
30189      return false;
30190    }
30191    function pick(leftBand) {
30192      for (var attempt = 0; attempt < 50; attempt++) {
30193        var top = Math.random() * 88 + 2;
30194        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30195        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30196      }
30197      var top = Math.random() * 88 + 2;
30198      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30199      placed.push([top, left]); return [top, left];
30200    }
30201    var half = Math.floor(wms.length / 2);
30202    wms.forEach(function (img, i) {
30203      var pos = pick(i < half);
30204      var size = Math.floor(Math.random() * 100 + 120);
30205      var rot = (Math.random() * 360).toFixed(1);
30206      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30207      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;
30208    });
30209  })();
30210  (function spawnCodeParticles() {
30211    var container = document.getElementById('code-particles');
30212    if (!container) return;
30213    var snippets = [
30214      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30215      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30216      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30217      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30218      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30219    ];
30220    var count = 38;
30221    for (var i = 0; i < count; i++) {
30222      (function(idx) {
30223        var el = document.createElement('span');
30224        el.className = 'code-particle';
30225        el.textContent = snippets[idx % snippets.length];
30226        var left = Math.random() * 94 + 2;
30227        var top = Math.random() * 88 + 6;
30228        var dur = (Math.random() * 10 + 9).toFixed(1);
30229        var delay = (Math.random() * 18).toFixed(1);
30230        var rot = (Math.random() * 26 - 13).toFixed(1);
30231        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30232        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30233        container.appendChild(el);
30234      })(i);
30235    }
30236  })();
30237})();
30238</script>
30239</body>
30240</html>
30241"##,
30242    ext = "html"
30243)]
30244pub(crate) struct LoginTemplate {
30245    pub(crate) csp_nonce: String,
30246    pub(crate) has_error: bool,
30247    pub(crate) next_url: String,
30248    pub(crate) lockout_threshold: u32,
30249}
30250
30251// ── REST API reference page ────────────────────────────────────────────────────
30252
30253#[derive(Template)]
30254#[template(
30255    source = r##"
30256<!doctype html>
30257<html lang="en">
30258<head>
30259  <meta charset="utf-8">
30260  <meta name="viewport" content="width=device-width, initial-scale=1">
30261  <title>OxideSLOC — REST API Reference</title>
30262  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30263  <style nonce="{{ csp_nonce }}">
30264    :root {
30265      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30266      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30267      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30268      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30269      --success:#16a34a;
30270    }
30271    body.dark-theme {
30272      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30273      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30274    }
30275    *{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;}
30276    .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);}
30277    .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;}
30278    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30279    .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));}
30280    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30281    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30282    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30283    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30284    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30285    @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; } }
30286    .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;}
30287    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30288    .nav-pill.active{background:rgba(255,255,255,0.22);}
30289    .nav-dropdown{position:relative;display:inline-flex;}
30290    .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;}
30291    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30292    .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;}
30293    .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;}
30294    .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);}
30295    .nav-dropdown-menu a:last-child{border-bottom:none;}
30296    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30297    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30298    .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;}
30299    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30300    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30301    .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;}
30302    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30303    .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);}
30304    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30305    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30306    .settings-modal-body{padding:14px 16px 16px;}
30307    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30308    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30309    .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;}
30310    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30311    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30312    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30313    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30314    .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;}
30315    .tz-select:focus{border-color:var(--oxide);}
30316    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30317    .page-header{margin-bottom:28px;}
30318    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30319    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30320    .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;}
30321    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30322    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30323    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30324    .callout strong{font-weight:800;}
30325    .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;}
30326    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30327    .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;}
30328    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30329    .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;}
30330    body.dark-theme .base-url-value{color:var(--accent);}
30331    .section{margin-bottom:36px;}
30332    .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);}
30333    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30334    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30335    .ep-header:hover{background:var(--surface-2);}
30336    .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;}
30337    .method.get{background:#dcfce7;color:#166534;}
30338    .method.post{background:#dbeafe;color:#1e40af;}
30339    .method.delete{background:#fee2e2;color:#991b1b;}
30340    body.dark-theme .method.get{background:#14532d;color:#86efac;}
30341    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30342    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30343    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30344    .ep-path .param{color:var(--oxide-2);}
30345    body.dark-theme .ep-path .param{color:var(--oxide);}
30346    .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;}
30347    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30348    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30349    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30350    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30351    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30352    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30353    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30354    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30355    .ep-card.open .chevron{transform:rotate(180deg);}
30356    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30357    .ep-card.open .ep-body{display:block;}
30358    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30359    .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;}
30360    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30361    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30362    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30363    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30364    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);}
30365    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30366    table.params tr:last-child td{border-bottom:none;}
30367    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30368    .pt-type{color:var(--muted-2);font-size:12px;}
30369    .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;}
30370    .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;}
30371    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30372    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30373    details.schema{margin-bottom:14px;}
30374    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;}
30375    details.schema summary:hover{color:var(--text);}
30376    .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;}
30377    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30378    .curl-wrap{position:relative;}
30379    .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;}
30380    .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;}
30381    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30382    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30383    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30384    .webhook-note a{color:var(--accent-2);text-decoration:none;}
30385    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30386    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30387    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30388    .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;}
30389    @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));}}
30390    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30391    .site-footer a{color:var(--muted);}
30392  </style>
30393</head>
30394<body>
30395  <div class="background-watermarks" aria-hidden="true">
30396    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30397    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30398    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30399    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30400    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30401    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30402    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30403  </div>
30404  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30405  <div class="top-nav">
30406    <div class="top-nav-inner">
30407      <a class="brand" href="/">
30408        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30409        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30410      </a>
30411      <div class="nav-right">
30412        <a class="nav-pill" href="/">Home</a>
30413        <div class="nav-dropdown">
30414          <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>
30415          <div class="nav-dropdown-menu">
30416            <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>
30417          </div>
30418        </div>
30419        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30420        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30421        <div class="nav-dropdown">
30422          <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>
30423          <div class="nav-dropdown-menu">
30424            <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>
30425          </div>
30426        </div>
30427        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30428          <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>
30429        </button>
30430        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30431          <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>
30432          <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>
30433        </button>
30434      </div>
30435    </div>
30436  </div>
30437
30438  <div class="page">
30439    <div class="page-header">
30440      <h1 class="page-title">REST API Reference</h1>
30441      <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>
30442    </div>
30443
30444    {% if has_api_key %}
30445    <div class="callout key-set">
30446      <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>
30447      <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>
30448    </div>
30449    {% else %}
30450    <div class="callout no-key">
30451      <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>
30452      <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>
30453    </div>
30454    {% endif %}
30455
30456    <div class="base-url-bar">
30457      <span class="base-url-label">Base URL</span>
30458      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30459    </div>
30460
30461    <!-- Health -->
30462    <div class="section">
30463      <h2 class="section-title">Health &amp; Status</h2>
30464      <div class="ep-card">
30465        <div class="ep-header">
30466          <span class="method get">GET</span>
30467          <span class="ep-path">/healthz</span>
30468          <span class="auth-badge public">Public</span>
30469          <span class="ep-desc">Server liveness check</span>
30470          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30471        </div>
30472        <div class="ep-body">
30473          <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>
30474          <p class="params-heading">Response</p>
30475          <div class="schema-block">200 OK
30476Content-Type: text/plain
30477
30478ok</div>
30479          <p class="curl-heading">Example</p>
30480          <div class="curl-wrap">
30481            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30482            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30483          </div>
30484        </div>
30485      </div>
30486    </div>
30487
30488    <!-- Badges -->
30489    <div class="section">
30490      <h2 class="section-title">Badges</h2>
30491      <div class="ep-card">
30492        <div class="ep-header">
30493          <span class="method get">GET</span>
30494          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30495          <span class="auth-badge public">Public</span>
30496          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30497          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30498        </div>
30499        <div class="ep-body">
30500          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30501          <p class="params-heading">Path Parameters</p>
30502          <table class="params">
30503            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30504            <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>
30505          </table>
30506          <p class="curl-heading">Example</p>
30507          <div class="curl-wrap">
30508            <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>
30509            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30510          </div>
30511        </div>
30512      </div>
30513    </div>
30514
30515    <!-- Metrics -->
30516    <div class="section">
30517      <h2 class="section-title">Metrics</h2>
30518
30519      <div class="ep-card">
30520        <div class="ep-header">
30521          <span class="method get">GET</span>
30522          <span class="ep-path">/api/metrics/latest</span>
30523          <span class="auth-badge protected">Protected</span>
30524          <span class="ep-desc">Latest scan metrics (JSON)</span>
30525          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30526        </div>
30527        <div class="ep-body">
30528          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30529          <details class="schema"><summary>Response schema</summary>
30530<div class="schema-block">{
30531  "run_id":    string,        // UUID
30532  "timestamp": string,        // ISO-8601 UTC
30533  "project":   string,        // scanned root path
30534  "summary": {
30535    "files_analyzed":       number,
30536    "files_skipped":        number,
30537    "code_lines":           number,
30538    "comment_lines":        number,
30539    "blank_lines":          number,
30540    "total_physical_lines": number,
30541    "functions":            number,
30542    "classes":              number,
30543    "variables":            number,
30544    "imports":              number
30545  },
30546  "languages": [
30547    { "name": string, "files": number, "code_lines": number,
30548      "comment_lines": number, "blank_lines": number,
30549      "functions": number, "classes": number,
30550      "variables": number, "imports": number }
30551  ]
30552}</div></details>
30553          <p class="curl-heading">Example</p>
30554          <div class="curl-wrap">
30555            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30556  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30557            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30558          </div>
30559        </div>
30560      </div>
30561
30562      <div class="ep-card">
30563        <div class="ep-header">
30564          <span class="method get">GET</span>
30565          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30566          <span class="auth-badge protected">Protected</span>
30567          <span class="ep-desc">Metrics for a specific run</span>
30568          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30569        </div>
30570        <div class="ep-body">
30571          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30572          <p class="params-heading">Path Parameters</p>
30573          <table class="params">
30574            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30575            <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>
30576          </table>
30577          <p class="curl-heading">Example</p>
30578          <div class="curl-wrap">
30579            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30580  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
30581            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30582          </div>
30583        </div>
30584      </div>
30585
30586      <div class="ep-card">
30587        <div class="ep-header">
30588          <span class="method get">GET</span>
30589          <span class="ep-path">/api/metrics/history</span>
30590          <span class="auth-badge protected">Protected</span>
30591          <span class="ep-desc">Paginated scan history</span>
30592          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30593        </div>
30594        <div class="ep-body">
30595          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30596          <p class="params-heading">Query Parameters</p>
30597          <table class="params">
30598            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30599            <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>
30600            <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>
30601          </table>
30602          <details class="schema"><summary>Response schema</summary>
30603<div class="schema-block">[{
30604  "run_id":         string,
30605  "timestamp":      string,   // ISO-8601 UTC
30606  "commit":         string | null,
30607  "branch":         string | null,
30608  "tags":           string[],
30609  "code_lines":     number,
30610  "comment_lines":  number,
30611  "blank_lines":    number,
30612  "physical_lines": number,
30613  "files_analyzed": number,
30614  "project_label":  string,
30615  "html_url":       string | null
30616}]</div></details>
30617          <p class="curl-heading">Example</p>
30618          <div class="curl-wrap">
30619            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30620  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30621            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30622          </div>
30623        </div>
30624      </div>
30625
30626      <div class="ep-card">
30627        <div class="ep-header">
30628          <span class="method get">GET</span>
30629          <span class="ep-path">/api/project-history</span>
30630          <span class="auth-badge protected">Protected</span>
30631          <span class="ep-desc">Project-level scan summary</span>
30632          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30633        </div>
30634        <div class="ep-body">
30635          <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>
30636          <p class="params-heading">Query Parameters</p>
30637          <table class="params">
30638            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30639            <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>
30640          </table>
30641          <details class="schema"><summary>Response schema</summary>
30642<div class="schema-block">{
30643  "scan_count":           number,
30644  "last_scan_id":         string | null,
30645  "last_scan_timestamp":  string | null,  // ISO-8601
30646  "last_scan_code_lines": number | null,
30647  "last_git_branch":      string | null,
30648  "last_git_commit":      string | null
30649}</div></details>
30650          <p class="curl-heading">Example</p>
30651          <div class="curl-wrap">
30652            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30653  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30654            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30655          </div>
30656        </div>
30657      </div>
30658
30659      <div class="ep-card">
30660        <div class="ep-header">
30661          <span class="method get">GET</span>
30662          <span class="ep-path">/api/metrics/submodules</span>
30663          <span class="auth-badge protected">Protected</span>
30664          <span class="ep-desc">List known git submodules across scans</span>
30665          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30666        </div>
30667        <div class="ep-body">
30668          <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>
30669          <p class="params-heading">Query Parameters</p>
30670          <table class="params">
30671            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30672            <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>
30673          </table>
30674          <details class="schema"><summary>Response schema</summary>
30675<div class="schema-block">[{
30676  "name":          string,  // submodule name
30677  "relative_path": string   // path relative to the project root
30678}]</div></details>
30679          <p class="curl-heading">Example</p>
30680          <div class="curl-wrap">
30681            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30682  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30683            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30684          </div>
30685        </div>
30686      </div>
30687    </div>
30688
30689    <!-- Async Run Status -->
30690    <div class="section">
30691      <h2 class="section-title">Async Run Status</h2>
30692
30693      <div class="ep-card">
30694        <div class="ep-header">
30695          <span class="method get">GET</span>
30696          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30697          <span class="auth-badge protected">Protected</span>
30698          <span class="ep-desc">Poll scan completion</span>
30699          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30700        </div>
30701        <div class="ep-body">
30702          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30703          <details class="schema"><summary>Response schema</summary>
30704<div class="schema-block">// Running
30705{ "state": "running",  "elapsed_secs": number }
30706
30707// Complete
30708{ "state": "complete", "run_id": string }
30709
30710// Failed
30711{ "state": "failed",   "message": string }</div></details>
30712          <p class="curl-heading">Example</p>
30713          <div class="curl-wrap">
30714            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30715  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
30716            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30717          </div>
30718        </div>
30719      </div>
30720
30721      <div class="ep-card">
30722        <div class="ep-header">
30723          <span class="method get">GET</span>
30724          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30725          <span class="auth-badge protected">Protected</span>
30726          <span class="ep-desc">Poll PDF generation readiness</span>
30727          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30728        </div>
30729        <div class="ep-body">
30730          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30731          <details class="schema"><summary>Response schema</summary>
30732<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30733          <p class="curl-heading">Example</p>
30734          <div class="curl-wrap">
30735            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30736  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
30737            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30738          </div>
30739        </div>
30740      </div>
30741
30742      <div class="ep-card">
30743        <div class="ep-header">
30744          <span class="method post">POST</span>
30745          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30746          <span class="auth-badge protected">Protected</span>
30747          <span class="ep-desc">Cancel a running scan</span>
30748          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30749        </div>
30750        <div class="ep-body">
30751          <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>
30752          <p class="curl-heading">Example</p>
30753          <div class="curl-wrap">
30754            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30755  -H "Authorization: Bearer $SLOC_API_KEY" \
30756  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
30757            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30758          </div>
30759        </div>
30760      </div>
30761    </div>
30762
30763    <!-- Run Management -->
30764    <div class="section">
30765      <h2 class="section-title">Run Management</h2>
30766
30767      <div class="ep-card">
30768        <div class="ep-header">
30769          <span class="method get">GET</span>
30770          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30771          <span class="auth-badge protected">Protected</span>
30772          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30773          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30774        </div>
30775        <div class="ep-body">
30776          <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>
30777          <p class="params-heading">Path Parameters</p>
30778          <table class="params">
30779            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30780            <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>
30781          </table>
30782          <details class="schema"><summary>Response</summary>
30783<div class="schema-block">200 OK — Content-Type: application/zip
30784Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
30785
30786404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
30787          <p class="curl-heading">Example</p>
30788          <div class="curl-wrap">
30789            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30790  -o run.zip \
30791  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
30792            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30793          </div>
30794        </div>
30795      </div>
30796
30797      <div class="ep-card">
30798        <div class="ep-header">
30799          <span class="method delete">DELETE</span>
30800          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30801          <span class="auth-badge protected">Protected</span>
30802          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30803          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30804        </div>
30805        <div class="ep-body">
30806          <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>
30807          <p class="params-heading">Path Parameters</p>
30808          <table class="params">
30809            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30810            <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>
30811          </table>
30812          <details class="schema"><summary>Response</summary>
30813<div class="schema-block">204 No Content — run successfully deleted
30814
30815500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
30816          <p class="curl-heading">Example</p>
30817          <div class="curl-wrap">
30818            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30819  -H "Authorization: Bearer $SLOC_API_KEY" \
30820  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
30821            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30822          </div>
30823        </div>
30824      </div>
30825
30826      <div class="ep-card">
30827        <div class="ep-header">
30828          <span class="method post">POST</span>
30829          <span class="ep-path">/api/runs/cleanup</span>
30830          <span class="auth-badge protected">Protected</span>
30831          <span class="ep-desc">Bulk delete runs older than N days</span>
30832          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30833        </div>
30834        <div class="ep-body">
30835          <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>
30836          <p class="params-heading">Request Body (application/json)</p>
30837          <table class="params">
30838            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30839            <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>
30840          </table>
30841          <details class="schema"><summary>Response schema</summary>
30842<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
30843          <p class="curl-heading">Example — delete runs older than 60 days</p>
30844          <div class="curl-wrap">
30845            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30846  -H "Authorization: Bearer $SLOC_API_KEY" \
30847  -H "Content-Type: application/json" \
30848  -d '{"older_than_days":60}' \
30849  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30850            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30851          </div>
30852        </div>
30853      </div>
30854    </div>
30855
30856    <!-- Retention Policy -->
30857    <div class="section">
30858      <h2 class="section-title">Retention Policy</h2>
30859
30860      <div class="ep-card">
30861        <div class="ep-header">
30862          <span class="method get">GET</span>
30863          <span class="ep-path">/api/cleanup-policy</span>
30864          <span class="auth-badge protected">Protected</span>
30865          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30866          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30867        </div>
30868        <div class="ep-body">
30869          <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>
30870          <details class="schema"><summary>Response schema</summary>
30871<div class="schema-block">{
30872  "policy": {
30873    "enabled":       boolean,
30874    "max_age_days":  number | null,   // delete runs older than N days
30875    "max_run_count": number | null,   // keep only the N most recent runs
30876    "interval_hours": number          // hours between background passes
30877  } | null,
30878  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
30879  "last_run_deleted": number | null   // runs deleted in last pass
30880}</div></details>
30881          <p class="curl-heading">Example</p>
30882          <div class="curl-wrap">
30883            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30884  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30885            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
30886          </div>
30887        </div>
30888      </div>
30889
30890      <div class="ep-card">
30891        <div class="ep-header">
30892          <span class="method post">POST</span>
30893          <span class="ep-path">/api/cleanup-policy</span>
30894          <span class="auth-badge protected">Protected</span>
30895          <span class="ep-desc">Save or update the retention policy</span>
30896          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30897        </div>
30898        <div class="ep-body">
30899          <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>
30900          <p class="params-heading">Request Body (application/json)</p>
30901          <table class="params">
30902            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30903            <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>
30904            <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>
30905            <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>
30906            <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>
30907          </table>
30908          <details class="schema"><summary>Response</summary>
30909<div class="schema-block">204 No Content — policy saved and task (re)started
30910
30911500 Internal Server Error — { "error": string }</div></details>
30912          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
30913          <div class="curl-wrap">
30914            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
30915  -H "Authorization: Bearer $SLOC_API_KEY" \
30916  -H "Content-Type: application/json" \
30917  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
30918  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30919            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
30920          </div>
30921        </div>
30922      </div>
30923
30924      <div class="ep-card">
30925        <div class="ep-header">
30926          <span class="method post">POST</span>
30927          <span class="ep-path">/api/cleanup-policy/run-now</span>
30928          <span class="auth-badge protected">Protected</span>
30929          <span class="ep-desc">Trigger an immediate cleanup pass</span>
30930          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30931        </div>
30932        <div class="ep-body">
30933          <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>
30934          <details class="schema"><summary>Response schema</summary>
30935<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
30936          <p class="curl-heading">Example</p>
30937          <div class="curl-wrap">
30938            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
30939  -H "Authorization: Bearer $SLOC_API_KEY" \
30940  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
30941            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
30942          </div>
30943        </div>
30944      </div>
30945
30946      <div class="ep-card">
30947        <div class="ep-header">
30948          <span class="method delete">DELETE</span>
30949          <span class="ep-path">/api/cleanup-policy</span>
30950          <span class="auth-badge protected">Protected</span>
30951          <span class="ep-desc">Remove the retention policy and stop the background task</span>
30952          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30953        </div>
30954        <div class="ep-body">
30955          <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>
30956          <details class="schema"><summary>Response</summary>
30957<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
30958          <p class="curl-heading">Example</p>
30959          <div class="curl-wrap">
30960            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
30961  -H "Authorization: Bearer $SLOC_API_KEY" \
30962  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30963            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
30964          </div>
30965        </div>
30966      </div>
30967    </div>
30968
30969    <!-- Scan Profiles -->
30970    <div class="section">
30971      <h2 class="section-title">Scan Profiles</h2>
30972
30973      <div class="ep-card">
30974        <div class="ep-header">
30975          <span class="method get">GET</span>
30976          <span class="ep-path">/api/scan-profiles</span>
30977          <span class="auth-badge protected">Protected</span>
30978          <span class="ep-desc">List saved scan profiles</span>
30979          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30980        </div>
30981        <div class="ep-body">
30982          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
30983          <details class="schema"><summary>Response schema</summary>
30984<div class="schema-block">{
30985  "profiles": [{
30986    "id":         string,   // UUID
30987    "name":       string,
30988    "created_at": string,   // ISO-8601
30989    "params":     object
30990  }]
30991}</div></details>
30992          <p class="curl-heading">Example</p>
30993          <div class="curl-wrap">
30994            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30995  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
30996            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
30997          </div>
30998        </div>
30999      </div>
31000
31001      <div class="ep-card">
31002        <div class="ep-header">
31003          <span class="method post">POST</span>
31004          <span class="ep-path">/api/scan-profiles</span>
31005          <span class="auth-badge protected">Protected</span>
31006          <span class="ep-desc">Save a scan profile</span>
31007          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31008        </div>
31009        <div class="ep-body">
31010          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31011          <p class="params-heading">Request Body (application/json)</p>
31012          <table class="params">
31013            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31014            <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>
31015            <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>
31016          </table>
31017          <details class="schema"><summary>Response schema</summary>
31018<div class="schema-block">{ "ok": true }</div></details>
31019          <p class="curl-heading">Example</p>
31020          <div class="curl-wrap">
31021            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31022  -H "Authorization: Bearer $SLOC_API_KEY" \
31023  -H "Content-Type: application/json" \
31024  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31025  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31026            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31027          </div>
31028        </div>
31029      </div>
31030
31031      <div class="ep-card">
31032        <div class="ep-header">
31033          <span class="method delete">DELETE</span>
31034          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31035          <span class="auth-badge protected">Protected</span>
31036          <span class="ep-desc">Delete a scan profile</span>
31037          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31038        </div>
31039        <div class="ep-body">
31040          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31041          <p class="params-heading">Path Parameters</p>
31042          <table class="params">
31043            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31044            <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>
31045          </table>
31046          <details class="schema"><summary>Response schema</summary>
31047<div class="schema-block">{ "ok": true }</div></details>
31048          <p class="curl-heading">Example</p>
31049          <div class="curl-wrap">
31050            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31051  -H "Authorization: Bearer $SLOC_API_KEY" \
31052  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31053            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31054          </div>
31055        </div>
31056      </div>
31057    </div>
31058
31059    <!-- Scheduled Scans -->
31060    <div class="section">
31061      <h2 class="section-title">Scheduled Scans</h2>
31062
31063      <div class="ep-card">
31064        <div class="ep-header">
31065          <span class="method get">GET</span>
31066          <span class="ep-path">/api/schedules</span>
31067          <span class="auth-badge protected">Protected</span>
31068          <span class="ep-desc">List configured schedules</span>
31069          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31070        </div>
31071        <div class="ep-body">
31072          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31073          <p class="curl-heading">Example</p>
31074          <div class="curl-wrap">
31075            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31076  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31077            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31078          </div>
31079        </div>
31080      </div>
31081
31082      <div class="ep-card">
31083        <div class="ep-header">
31084          <span class="method post">POST</span>
31085          <span class="ep-path">/api/schedules</span>
31086          <span class="auth-badge protected">Protected</span>
31087          <span class="ep-desc">Create a schedule</span>
31088          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31089        </div>
31090        <div class="ep-body">
31091          <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>
31092          <p class="curl-heading">Example</p>
31093          <div class="curl-wrap">
31094            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31095  -H "Authorization: Bearer $SLOC_API_KEY" \
31096  -H "Content-Type: application/json" \
31097  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31098  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31099            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31100          </div>
31101        </div>
31102      </div>
31103
31104      <div class="ep-card">
31105        <div class="ep-header">
31106          <span class="method delete">DELETE</span>
31107          <span class="ep-path">/api/schedules</span>
31108          <span class="auth-badge protected">Protected</span>
31109          <span class="ep-desc">Delete a schedule</span>
31110          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31111        </div>
31112        <div class="ep-body">
31113          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31114          <p class="curl-heading">Example</p>
31115          <div class="curl-wrap">
31116            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31117  -H "Authorization: Bearer $SLOC_API_KEY" \
31118  -H "Content-Type: application/json" \
31119  -d '{"id":"&lt;schedule_id&gt;"}' \
31120  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31121            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31122          </div>
31123        </div>
31124      </div>
31125    </div>
31126
31127    <!-- Git Browser -->
31128    <div class="section">
31129      <h2 class="section-title">Git Browser</h2>
31130
31131      <div class="ep-card">
31132        <div class="ep-header">
31133          <span class="method get">GET</span>
31134          <span class="ep-path">/api/git/refs</span>
31135          <span class="auth-badge protected">Protected</span>
31136          <span class="ep-desc">List git refs for a repository</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">Returns all branches and tags for a local git repository.</p>
31141          <p class="params-heading">Query Parameters</p>
31142          <table class="params">
31143            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31144            <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>
31145          </table>
31146          <p class="curl-heading">Example</p>
31147          <div class="curl-wrap">
31148            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31149  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31150            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31151          </div>
31152        </div>
31153      </div>
31154
31155      <div class="ep-card">
31156        <div class="ep-header">
31157          <span class="method get">GET</span>
31158          <span class="ep-path">/api/git/scan-ref</span>
31159          <span class="auth-badge protected">Protected</span>
31160          <span class="ep-desc">SLOC-scan a specific git ref</span>
31161          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31162        </div>
31163        <div class="ep-body">
31164          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31165          <p class="params-heading">Query Parameters</p>
31166          <table class="params">
31167            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31168            <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>
31169            <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>
31170          </table>
31171          <p class="curl-heading">Example</p>
31172          <div class="curl-wrap">
31173            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31174  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
31175            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31176          </div>
31177        </div>
31178      </div>
31179
31180      <div class="ep-card">
31181        <div class="ep-header">
31182          <span class="method get">GET</span>
31183          <span class="ep-path">/api/git/compare-refs</span>
31184          <span class="auth-badge protected">Protected</span>
31185          <span class="ep-desc">Compare SLOC across two git refs</span>
31186          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31187        </div>
31188        <div class="ep-body">
31189          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31190          <p class="params-heading">Query Parameters</p>
31191          <table class="params">
31192            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31193            <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>
31194            <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>
31195            <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>
31196          </table>
31197          <p class="curl-heading">Example</p>
31198          <div class="curl-wrap">
31199            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31200  "<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>
31201            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31202          </div>
31203        </div>
31204      </div>
31205    </div>
31206
31207    <!-- Webhooks -->
31208    <div class="section">
31209      <h2 class="section-title">Webhooks</h2>
31210      <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>
31211
31212      <div class="ep-card">
31213        <div class="ep-header">
31214          <span class="method post">POST</span>
31215          <span class="ep-path">/webhooks/github</span>
31216          <span class="auth-badge hmac">HMAC</span>
31217          <span class="ep-desc">GitHub push event receiver</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">Receives GitHub <code>push</code> events and triggers an SLOC scan. Authenticated via <code>X-Hub-Signature-256</code> HMAC-SHA256.</p>
31222          <p class="params-heading">Required Headers</p>
31223          <table class="params">
31224            <tr><th>Header</th><th>Value</th></tr>
31225            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31226            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31227            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31228          </table>
31229        </div>
31230      </div>
31231
31232      <div class="ep-card">
31233        <div class="ep-header">
31234          <span class="method post">POST</span>
31235          <span class="ep-path">/webhooks/gitlab</span>
31236          <span class="auth-badge hmac">HMAC</span>
31237          <span class="ep-desc">GitLab push event receiver</span>
31238          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31239        </div>
31240        <div class="ep-body">
31241          <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>
31242          <p class="params-heading">Required Headers</p>
31243          <table class="params">
31244            <tr><th>Header</th><th>Value</th></tr>
31245            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31246            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31247            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31248          </table>
31249        </div>
31250      </div>
31251
31252      <div class="ep-card">
31253        <div class="ep-header">
31254          <span class="method post">POST</span>
31255          <span class="ep-path">/webhooks/bitbucket</span>
31256          <span class="auth-badge hmac">HMAC</span>
31257          <span class="ep-desc">Bitbucket push event receiver</span>
31258          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31259        </div>
31260        <div class="ep-body">
31261          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31262          <p class="params-heading">Required Headers</p>
31263          <table class="params">
31264            <tr><th>Header</th><th>Value</th></tr>
31265            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31266            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31267          </table>
31268        </div>
31269      </div>
31270    </div>
31271
31272    <!-- Config -->
31273    <div class="section">
31274      <h2 class="section-title">Config Import / Export</h2>
31275
31276      <div class="ep-card">
31277        <div class="ep-header">
31278          <span class="method get">GET</span>
31279          <span class="ep-path">/export-config</span>
31280          <span class="auth-badge protected">Protected</span>
31281          <span class="ep-desc">Export server configuration as JSON</span>
31282          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31283        </div>
31284        <div class="ep-body">
31285          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31286          <p class="curl-heading">Example</p>
31287          <div class="curl-wrap">
31288            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31289  -o config.json \
31290  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31291            <button class="curl-copy-btn" data-target="c-export">Copy</button>
31292          </div>
31293        </div>
31294      </div>
31295
31296      <div class="ep-card">
31297        <div class="ep-header">
31298          <span class="method post">POST</span>
31299          <span class="ep-path">/import-config</span>
31300          <span class="auth-badge protected">Protected</span>
31301          <span class="ep-desc">Import server configuration</span>
31302          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31303        </div>
31304        <div class="ep-body">
31305          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31306          <p class="curl-heading">Example</p>
31307          <div class="curl-wrap">
31308            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31309  -H "Authorization: Bearer $SLOC_API_KEY" \
31310  -H "Content-Type: application/json" \
31311  -d @config.json \
31312  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31313            <button class="curl-copy-btn" data-target="c-import">Copy</button>
31314          </div>
31315        </div>
31316      </div>
31317    </div>
31318
31319    <!-- CI Ingest -->
31320    <div class="section">
31321      <h2 class="section-title">CI Ingest</h2>
31322
31323      <div class="ep-card">
31324        <div class="ep-header">
31325          <span class="method post">POST</span>
31326          <span class="ep-path">/api/ingest</span>
31327          <span class="auth-badge protected">Protected</span>
31328          <span class="ep-desc">Push a pre-computed scan result from CI</span>
31329          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31330        </div>
31331        <div class="ep-body">
31332          <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>
31333          <p class="params-heading">Query Parameters</p>
31334          <table class="params">
31335            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31336            <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>
31337          </table>
31338          <p class="params-heading">Request Body (application/json)</p>
31339          <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>
31340          <details class="schema"><summary>Response schema</summary>
31341<div class="schema-block">// 201 Created
31342{
31343  "run_id":   string,  // UUID of the ingested run
31344  "view_url": string   // relative URL to the report page
31345}</div></details>
31346          <p class="curl-heading">Example</p>
31347          <div class="curl-wrap">
31348            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31349  -H "Authorization: Bearer $SLOC_API_KEY" \
31350  -H "Content-Type: application/json" \
31351  -d @result.json \
31352  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31353            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31354          </div>
31355        </div>
31356      </div>
31357    </div>
31358
31359    <!-- Artifact Download -->
31360    <div class="section">
31361      <h2 class="section-title">Artifact Download</h2>
31362
31363      <div class="ep-card">
31364        <div class="ep-header">
31365          <span class="method get">GET</span>
31366          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31367          <span class="auth-badge protected">Protected</span>
31368          <span class="ep-desc">Download or view a scan artifact</span>
31369          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31370        </div>
31371        <div class="ep-body">
31372          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31373          <p class="params-heading">Path Parameters</p>
31374          <table class="params">
31375            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31376            <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>
31377            <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>
31378          </table>
31379          <p class="params-heading">Query Parameters</p>
31380          <table class="params">
31381            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31382            <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>
31383          </table>
31384          <p class="curl-heading">Example — download JSON result</p>
31385          <div class="curl-wrap">
31386            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31387  -o result.json \
31388  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
31389            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31390          </div>
31391        </div>
31392      </div>
31393    </div>
31394
31395    <!-- Embed Widget -->
31396    <div class="section">
31397      <h2 class="section-title">Embed Widget</h2>
31398
31399      <div class="ep-card">
31400        <div class="ep-header">
31401          <span class="method get">GET</span>
31402          <span class="ep-path">/embed/summary</span>
31403          <span class="auth-badge protected">Protected</span>
31404          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31405          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31406        </div>
31407        <div class="ep-body">
31408          <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>
31409          <p class="params-heading">Query Parameters</p>
31410          <table class="params">
31411            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31412            <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>
31413            <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>
31414          </table>
31415          <p class="curl-heading">Example</p>
31416          <div class="curl-wrap">
31417            <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"
31418        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
31419            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31420          </div>
31421        </div>
31422      </div>
31423    </div>
31424
31425    <!-- Confluence Integration -->
31426    <div class="section">
31427      <h2 class="section-title">Confluence Integration</h2>
31428
31429      <div class="ep-card">
31430        <div class="ep-header">
31431          <span class="method get">GET</span>
31432          <span class="ep-path">/api/confluence/config</span>
31433          <span class="auth-badge protected">Protected</span>
31434          <span class="ep-desc">Get current Confluence configuration</span>
31435          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31436        </div>
31437        <div class="ep-body">
31438          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31439          <details class="schema"><summary>Response schema</summary>
31440<div class="schema-block">{
31441  "configured":     boolean,
31442  "tier":           "cloud" | "server",
31443  "base_url":       string,
31444  "username":       string,
31445  "api_token_set":  boolean,
31446  "space_key":      string,
31447  "parent_page_id": string | null,
31448  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
31449}</div></details>
31450          <p class="curl-heading">Example</p>
31451          <div class="curl-wrap">
31452            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31453  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31454            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31455          </div>
31456        </div>
31457      </div>
31458
31459      <div class="ep-card">
31460        <div class="ep-header">
31461          <span class="method post">POST</span>
31462          <span class="ep-path">/api/confluence/config</span>
31463          <span class="auth-badge protected">Protected</span>
31464          <span class="ep-desc">Save Confluence configuration</span>
31465          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31466        </div>
31467        <div class="ep-body">
31468          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31469          <p class="params-heading">Request Body (application/json)</p>
31470          <table class="params">
31471            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31472            <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>
31473            <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>
31474            <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>
31475            <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>
31476            <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>
31477            <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>
31478            <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>
31479          </table>
31480          <details class="schema"><summary>Response schema</summary>
31481<div class="schema-block">{ "ok": true }</div></details>
31482          <p class="curl-heading">Example</p>
31483          <div class="curl-wrap">
31484            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31485  -H "Authorization: Bearer $SLOC_API_KEY" \
31486  -H "Content-Type: application/json" \
31487  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31488  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31489            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31490          </div>
31491        </div>
31492      </div>
31493
31494      <div class="ep-card">
31495        <div class="ep-header">
31496          <span class="method post">POST</span>
31497          <span class="ep-path">/api/confluence/test</span>
31498          <span class="auth-badge protected">Protected</span>
31499          <span class="ep-desc">Test Confluence connection</span>
31500          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31501        </div>
31502        <div class="ep-body">
31503          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31504          <details class="schema"><summary>Response schema</summary>
31505<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31506          <p class="curl-heading">Example</p>
31507          <div class="curl-wrap">
31508            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31509  -H "Authorization: Bearer $SLOC_API_KEY" \
31510  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31511            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31512          </div>
31513        </div>
31514      </div>
31515
31516      <div class="ep-card">
31517        <div class="ep-header">
31518          <span class="method post">POST</span>
31519          <span class="ep-path">/api/confluence/post</span>
31520          <span class="auth-badge protected">Protected</span>
31521          <span class="ep-desc">Publish a scan report to Confluence</span>
31522          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31523        </div>
31524        <div class="ep-body">
31525          <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>
31526          <p class="params-heading">Request Body (application/json)</p>
31527          <table class="params">
31528            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31529            <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>
31530            <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>
31531            <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>
31532          </table>
31533          <details class="schema"><summary>Response schema</summary>
31534<div class="schema-block">// 200 OK
31535{ "ok": true, "page_id": string }
31536
31537// 400 / 502 on error
31538{ "ok": false, "error": string }</div></details>
31539          <p class="curl-heading">Example</p>
31540          <div class="curl-wrap">
31541            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31542  -H "Authorization: Bearer $SLOC_API_KEY" \
31543  -H "Content-Type: application/json" \
31544  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
31545  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31546            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31547          </div>
31548        </div>
31549      </div>
31550
31551      <div class="ep-card">
31552        <div class="ep-header">
31553          <span class="method get">GET</span>
31554          <span class="ep-path">/api/confluence/wiki-markup</span>
31555          <span class="auth-badge protected">Protected</span>
31556          <span class="ep-desc">Get Confluence wiki markup for a run</span>
31557          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31558        </div>
31559        <div class="ep-body">
31560          <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>
31561          <p class="params-heading">Query Parameters</p>
31562          <table class="params">
31563            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31564            <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>
31565          </table>
31566          <p class="curl-heading">Example</p>
31567          <div class="curl-wrap">
31568            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31569  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
31570            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31571          </div>
31572        </div>
31573      </div>
31574    </div>
31575
31576    <!-- Authentication -->
31577    <div class="section">
31578      <h2 class="section-title">Authentication</h2>
31579      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31580
31581      <div class="ep-card">
31582        <div class="ep-header">
31583          <span class="method get">GET</span>
31584          <span class="ep-path">/auth/login</span>
31585          <span class="auth-badge public">Public</span>
31586          <span class="ep-desc">Login page</span>
31587          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31588        </div>
31589        <div class="ep-body">
31590          <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>
31591          <p class="params-heading">Query Parameters</p>
31592          <table class="params">
31593            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31594            <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>
31595            <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>
31596          </table>
31597        </div>
31598      </div>
31599
31600      <div class="ep-card">
31601        <div class="ep-header">
31602          <span class="method post">POST</span>
31603          <span class="ep-path">/auth/login</span>
31604          <span class="auth-badge public">Public</span>
31605          <span class="ep-desc">Submit credentials and get a session cookie</span>
31606          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31607        </div>
31608        <div class="ep-body">
31609          <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>
31610          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31611          <table class="params">
31612            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31613            <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>
31614            <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>
31615          </table>
31616          <p class="curl-heading">Example</p>
31617          <div class="curl-wrap">
31618            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31619  -d "key=$SLOC_API_KEY&amp;next=/" \
31620  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31621            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31622          </div>
31623        </div>
31624      </div>
31625    </div>
31626
31627    <!-- Coverage Suggestion -->
31628    <div class="section">
31629      <h2 class="section-title">Coverage Suggestion</h2>
31630
31631      <div class="ep-card">
31632        <div class="ep-header">
31633          <span class="method get">GET</span>
31634          <span class="ep-path">/api/suggest-coverage</span>
31635          <span class="auth-badge protected">Protected</span>
31636          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31637          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31638        </div>
31639        <div class="ep-body">
31640          <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>
31641          <p class="params-heading">Query Parameters</p>
31642          <table class="params">
31643            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31644            <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>
31645          </table>
31646          <details class="schema"><summary>Response schema</summary>
31647<div class="schema-block">{
31648  "found": string | null,  // absolute path to the coverage file, if detected
31649  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31650  "hint":  string | null   // shell command to generate coverage if not found
31651}</div></details>
31652          <p class="curl-heading">Example</p>
31653          <div class="curl-wrap">
31654            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31655  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31656            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31657          </div>
31658        </div>
31659      </div>
31660    </div>
31661
31662  </div>
31663
31664  <footer class="site-footer">
31665    local code analysis - metrics, history and reports
31666    &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>
31667    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31668    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31669    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31670    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
31671  </footer>
31672
31673  <script nonce="{{ csp_nonce }}">
31674    (function () {
31675      var base = window.location.origin;
31676      document.getElementById('base-url').textContent = base;
31677      document.querySelectorAll('.base-url-slot').forEach(function (el) {
31678        el.textContent = base;
31679      });
31680
31681      document.querySelectorAll('.ep-header').forEach(function (hdr) {
31682        hdr.addEventListener('click', function () {
31683          hdr.closest('.ep-card').classList.toggle('open');
31684        });
31685      });
31686
31687      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31688        btn.addEventListener('click', function () {
31689          var targetId = btn.dataset.target;
31690          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31691          if (!pre) return;
31692          navigator.clipboard.writeText(pre.textContent).then(function () {
31693            btn.textContent = 'Copied!';
31694            btn.classList.add('copied');
31695            setTimeout(function () {
31696              btn.textContent = 'Copy';
31697              btn.classList.remove('copied');
31698            }, 2000);
31699          });
31700        });
31701      });
31702
31703      var storageKey = 'oxide-sloc-theme';
31704      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31705      var themeBtn = document.getElementById('theme-toggle');
31706      if (themeBtn) {
31707        themeBtn.addEventListener('click', function () {
31708          var dark = document.body.classList.toggle('dark-theme');
31709          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31710        });
31711      }
31712      (function() {
31713        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'}];
31714        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);});}
31715        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31716        var btn=document.getElementById('settings-btn');if(!btn)return;
31717        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31718        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>';
31719        document.body.appendChild(m);
31720        var g=document.getElementById('scheme-grid');
31721        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);});
31722        var cl=document.getElementById('settings-close');
31723        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);
31724        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');});
31725        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31726        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31727      })();
31728      (function randomizeWatermarks() {
31729        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31730        if (!wms.length) return;
31731        var placed = [];
31732        function tooClose(top, left) {
31733          for (var i = 0; i < placed.length; i++) {
31734            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31735            if (dt < 16 && dl < 12) return true;
31736          }
31737          return false;
31738        }
31739        function pick(leftBand) {
31740          for (var attempt = 0; attempt < 50; attempt++) {
31741            var top = Math.random() * 88 + 2;
31742            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31743            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31744          }
31745          var top = Math.random() * 88 + 2;
31746          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31747          placed.push([top, left]); return [top, left];
31748        }
31749        var half = Math.floor(wms.length / 2);
31750        wms.forEach(function (img, i) {
31751          var pos = pick(i < half);
31752          var size = Math.floor(Math.random() * 100 + 120);
31753          var rot = (Math.random() * 360).toFixed(1);
31754          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31755          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;
31756        });
31757      })();
31758      (function spawnCodeParticles() {
31759        var container = document.getElementById('code-particles');
31760        if (!container) return;
31761        var snippets = [
31762          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31763          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31764          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31765          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31766          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31767        ];
31768        var count = 38;
31769        for (var i = 0; i < count; i++) {
31770          (function(idx) {
31771            var el = document.createElement('span');
31772            el.className = 'code-particle';
31773            el.textContent = snippets[idx % snippets.length];
31774            var left = Math.random() * 94 + 2;
31775            var top = Math.random() * 88 + 6;
31776            var dur = (Math.random() * 10 + 9).toFixed(1);
31777            var delay = (Math.random() * 18).toFixed(1);
31778            var rot = (Math.random() * 26 - 13).toFixed(1);
31779            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31780            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31781            container.appendChild(el);
31782          })(i);
31783        }
31784      })();
31785    }());
31786  </script>
31787</body>
31788</html>
31789"##,
31790    ext = "html"
31791)]
31792struct ApiDocsTemplate {
31793    has_api_key: bool,
31794    csp_nonce: String,
31795    version: &'static str,
31796}
31797
31798#[cfg(test)]
31799mod form_config_tests {
31800    use super::*;
31801    use sloc_config::{
31802        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31803    };
31804
31805    fn blank_form() -> AnalyzeForm {
31806        AnalyzeForm {
31807            path: ".".to_string(),
31808            git_repo: None,
31809            git_ref: None,
31810            mixed_line_policy: None,
31811            python_docstrings_as_comments: None,
31812            generated_file_detection: None,
31813            minified_file_detection: None,
31814            vendor_directory_detection: None,
31815            include_lockfiles: None,
31816            binary_file_behavior: None,
31817            output_dir: None,
31818            report_title: None,
31819            report_header_footer: None,
31820            include_globs: None,
31821            exclude_globs: None,
31822            submodule_breakdown: None,
31823            coverage_file: None,
31824            continuation_line_policy: None,
31825            blank_in_block_comment_policy: None,
31826            count_compiler_directives: None,
31827            style_col_threshold: None,
31828            style_analysis_enabled: None,
31829            style_score_threshold: None,
31830            style_lang_scope: None,
31831            cocomo_mode: None,
31832            complexity_alert: None,
31833            exclude_duplicates: None,
31834            activity_window: None,
31835        }
31836    }
31837
31838    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31839        let mut cfg = sloc_config::AppConfig::default();
31840        apply_form_to_config(&mut cfg, form);
31841        cfg
31842    }
31843
31844    // ── activity_window (git hotspots — on by default) ──
31845
31846    #[test]
31847    fn extract_long_commit_picks_super_repo_by_short_prefix() {
31848        // A pretty-printed JSON tail containing several submodule git_commit_long
31849        // values plus the super-repo's; the helper must return the one whose hash
31850        // starts with the known short SHA, ignoring the others and any null value.
31851        let dir = tempfile::tempdir().unwrap();
31852        let path = dir.path().join("result.json");
31853        let body = r#"{
31854  "submodules": [
31855    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31856    { "git_commit_long": null }
31857  ],
31858  "git_commit_short": "4c2cd9b",
31859  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31860}"#;
31861        std::fs::write(&path, body).unwrap();
31862        assert_eq!(
31863            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31864            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31865        );
31866        // No match for an unrelated short SHA, and empty short yields None.
31867        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31868        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31869    }
31870
31871    #[test]
31872    fn activity_window_defaults_on_when_field_blank() {
31873        // Blank form field keeps the config default (90 days).
31874        let cfg = apply(&blank_form());
31875        assert_eq!(cfg.analysis.activity_window_days, Some(90));
31876    }
31877
31878    #[test]
31879    fn activity_window_override_sets_days() {
31880        let mut form = blank_form();
31881        form.activity_window = Some("30".to_string());
31882        let cfg = apply(&form);
31883        assert_eq!(cfg.analysis.activity_window_days, Some(30));
31884    }
31885
31886    #[test]
31887    fn activity_window_zero_disables() {
31888        // An explicit 0 from the form disables hotspots (overrides the default-on).
31889        let mut form = blank_form();
31890        form.activity_window = Some("0".to_string());
31891        let cfg = apply(&form);
31892        assert_eq!(cfg.analysis.activity_window_days, Some(0));
31893    }
31894
31895    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
31896
31897    #[test]
31898    fn python_docstrings_false_when_unchecked() {
31899        // Checkbox absent in form data (unchecked) → field must be false.
31900        let cfg = apply(&blank_form());
31901        assert!(
31902            !cfg.analysis.python_docstrings_as_comments,
31903            "absent python_docstrings_as_comments must map to false"
31904        );
31905    }
31906
31907    #[test]
31908    fn python_docstrings_true_when_checked() {
31909        // Browser sends "on" (no value= attr on the checkbox).
31910        let mut form = blank_form();
31911        form.python_docstrings_as_comments = Some("on".to_string());
31912        let cfg = apply(&form);
31913        assert!(cfg.analysis.python_docstrings_as_comments);
31914    }
31915
31916    #[test]
31917    fn python_docstrings_true_for_any_non_none_value() {
31918        // The handler uses .is_some() — any non-None value means "checked".
31919        let mut form = blank_form();
31920        form.python_docstrings_as_comments = Some("true".to_string());
31921        assert!(apply(&form).analysis.python_docstrings_as_comments);
31922    }
31923
31924    // ── submodule_breakdown (checkbox with value="enabled") ──
31925
31926    #[test]
31927    fn submodule_breakdown_false_when_unchecked() {
31928        let cfg = apply(&blank_form());
31929        assert!(
31930            !cfg.discovery.submodule_breakdown,
31931            "absent submodule_breakdown must map to false"
31932        );
31933    }
31934
31935    #[test]
31936    fn submodule_breakdown_true_when_value_enabled() {
31937        let mut form = blank_form();
31938        form.submodule_breakdown = Some("enabled".to_string());
31939        assert!(apply(&form).discovery.submodule_breakdown);
31940    }
31941
31942    #[test]
31943    fn submodule_breakdown_false_for_wrong_value() {
31944        // If somehow a value other than "enabled" is sent, it must still be false.
31945        let mut form = blank_form();
31946        form.submodule_breakdown = Some("on".to_string());
31947        assert!(
31948            !apply(&form).discovery.submodule_breakdown,
31949            "submodule_breakdown only becomes true for the exact value 'enabled'"
31950        );
31951    }
31952
31953    // ── generated_file_detection (select: "enabled" | "disabled") ──
31954
31955    #[test]
31956    fn generated_detection_true_when_enabled() {
31957        let mut form = blank_form();
31958        form.generated_file_detection = Some("enabled".to_string());
31959        assert!(apply(&form).analysis.generated_file_detection);
31960    }
31961
31962    #[test]
31963    fn generated_detection_false_when_disabled() {
31964        let mut form = blank_form();
31965        form.generated_file_detection = Some("disabled".to_string());
31966        assert!(!apply(&form).analysis.generated_file_detection);
31967    }
31968
31969    #[test]
31970    fn generated_detection_true_when_absent() {
31971        // None != Some("disabled") → true (safe default)
31972        assert!(
31973            apply(&blank_form()).analysis.generated_file_detection,
31974            "absent field must default to true (detection on)"
31975        );
31976    }
31977
31978    // ── minified_file_detection ──
31979
31980    #[test]
31981    fn minified_detection_false_when_disabled() {
31982        let mut form = blank_form();
31983        form.minified_file_detection = Some("disabled".to_string());
31984        assert!(!apply(&form).analysis.minified_file_detection);
31985    }
31986
31987    #[test]
31988    fn minified_detection_true_when_enabled() {
31989        let mut form = blank_form();
31990        form.minified_file_detection = Some("enabled".to_string());
31991        assert!(apply(&form).analysis.minified_file_detection);
31992    }
31993
31994    #[test]
31995    fn minified_detection_true_when_absent() {
31996        assert!(apply(&blank_form()).analysis.minified_file_detection);
31997    }
31998
31999    // ── vendor_directory_detection ──
32000
32001    #[test]
32002    fn vendor_detection_false_when_disabled() {
32003        let mut form = blank_form();
32004        form.vendor_directory_detection = Some("disabled".to_string());
32005        assert!(!apply(&form).analysis.vendor_directory_detection);
32006    }
32007
32008    #[test]
32009    fn vendor_detection_true_when_enabled() {
32010        let mut form = blank_form();
32011        form.vendor_directory_detection = Some("enabled".to_string());
32012        assert!(apply(&form).analysis.vendor_directory_detection);
32013    }
32014
32015    #[test]
32016    fn vendor_detection_true_when_absent() {
32017        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32018    }
32019
32020    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32021
32022    #[test]
32023    fn lockfiles_false_when_absent() {
32024        // None == Some("enabled") is false → lockfiles off (correct safe default)
32025        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32026    }
32027
32028    #[test]
32029    fn lockfiles_false_when_disabled() {
32030        let mut form = blank_form();
32031        form.include_lockfiles = Some("disabled".to_string());
32032        assert!(!apply(&form).analysis.include_lockfiles);
32033    }
32034
32035    #[test]
32036    fn lockfiles_true_when_enabled() {
32037        let mut form = blank_form();
32038        form.include_lockfiles = Some("enabled".to_string());
32039        assert!(apply(&form).analysis.include_lockfiles);
32040    }
32041
32042    // ── count_compiler_directives ──
32043
32044    #[test]
32045    fn compiler_directives_true_when_absent() {
32046        assert!(
32047            apply(&blank_form()).analysis.count_compiler_directives,
32048            "absent count_compiler_directives must default to true"
32049        );
32050    }
32051
32052    #[test]
32053    fn compiler_directives_true_when_enabled() {
32054        let mut form = blank_form();
32055        form.count_compiler_directives = Some("enabled".to_string());
32056        assert!(apply(&form).analysis.count_compiler_directives);
32057    }
32058
32059    #[test]
32060    fn compiler_directives_false_when_disabled() {
32061        let mut form = blank_form();
32062        form.count_compiler_directives = Some("disabled".to_string());
32063        assert!(!apply(&form).analysis.count_compiler_directives);
32064    }
32065
32066    // ── mixed_line_policy (enum select) ──
32067
32068    #[test]
32069    fn mixed_policy_unchanged_when_absent() {
32070        // None → if-let does nothing → stays at config default (CodeOnly)
32071        assert_eq!(
32072            apply(&blank_form()).analysis.mixed_line_policy,
32073            MixedLinePolicy::CodeOnly
32074        );
32075    }
32076
32077    #[test]
32078    fn mixed_policy_code_only() {
32079        let mut form = blank_form();
32080        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32081        assert_eq!(
32082            apply(&form).analysis.mixed_line_policy,
32083            MixedLinePolicy::CodeOnly
32084        );
32085    }
32086
32087    #[test]
32088    fn mixed_policy_code_and_comment() {
32089        let mut form = blank_form();
32090        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32091        assert_eq!(
32092            apply(&form).analysis.mixed_line_policy,
32093            MixedLinePolicy::CodeAndComment
32094        );
32095    }
32096
32097    #[test]
32098    fn mixed_policy_comment_only() {
32099        let mut form = blank_form();
32100        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32101        assert_eq!(
32102            apply(&form).analysis.mixed_line_policy,
32103            MixedLinePolicy::CommentOnly
32104        );
32105    }
32106
32107    #[test]
32108    fn mixed_policy_separate_mixed_category() {
32109        let mut form = blank_form();
32110        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32111        assert_eq!(
32112            apply(&form).analysis.mixed_line_policy,
32113            MixedLinePolicy::SeparateMixedCategory
32114        );
32115    }
32116
32117    // ── binary_file_behavior (enum select) ──
32118
32119    #[test]
32120    fn binary_behavior_skip_when_absent() {
32121        assert_eq!(
32122            apply(&blank_form()).analysis.binary_file_behavior,
32123            BinaryFileBehavior::Skip
32124        );
32125    }
32126
32127    #[test]
32128    fn binary_behavior_skip() {
32129        let mut form = blank_form();
32130        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32131        assert_eq!(
32132            apply(&form).analysis.binary_file_behavior,
32133            BinaryFileBehavior::Skip
32134        );
32135    }
32136
32137    #[test]
32138    fn binary_behavior_fail() {
32139        let mut form = blank_form();
32140        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32141        assert_eq!(
32142            apply(&form).analysis.binary_file_behavior,
32143            BinaryFileBehavior::Fail
32144        );
32145    }
32146
32147    // ── continuation_line_policy (enum select) ──
32148
32149    #[test]
32150    fn continuation_policy_each_physical_when_absent() {
32151        assert_eq!(
32152            apply(&blank_form()).analysis.continuation_line_policy,
32153            ContinuationLinePolicy::EachPhysicalLine
32154        );
32155    }
32156
32157    #[test]
32158    fn continuation_policy_collapse_to_logical() {
32159        let mut form = blank_form();
32160        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32161        assert_eq!(
32162            apply(&form).analysis.continuation_line_policy,
32163            ContinuationLinePolicy::CollapseToLogical
32164        );
32165    }
32166
32167    // ── blank_in_block_comment_policy (enum select) ──
32168
32169    #[test]
32170    fn blank_in_block_comment_count_as_comment_when_absent() {
32171        assert_eq!(
32172            apply(&blank_form()).analysis.blank_in_block_comment_policy,
32173            BlankInBlockCommentPolicy::CountAsComment
32174        );
32175    }
32176
32177    #[test]
32178    fn blank_in_block_comment_count_as_blank() {
32179        let mut form = blank_form();
32180        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32181        assert_eq!(
32182            apply(&form).analysis.blank_in_block_comment_policy,
32183            BlankInBlockCommentPolicy::CountAsBlank
32184        );
32185    }
32186
32187    // ── style_col_threshold ──
32188
32189    #[test]
32190    fn style_threshold_80() {
32191        let mut form = blank_form();
32192        form.style_col_threshold = Some("80".to_string());
32193        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32194    }
32195
32196    #[test]
32197    fn style_threshold_100() {
32198        let mut form = blank_form();
32199        form.style_col_threshold = Some("100".to_string());
32200        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32201    }
32202
32203    #[test]
32204    fn style_threshold_120() {
32205        let mut form = blank_form();
32206        form.style_col_threshold = Some("120".to_string());
32207        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32208    }
32209
32210    #[test]
32211    fn style_threshold_invalid_value_leaves_default() {
32212        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
32213        let mut cfg = sloc_config::AppConfig::default();
32214        let mut form = blank_form();
32215        form.style_col_threshold = Some("42".to_string());
32216        apply_form_to_config(&mut cfg, &form);
32217        assert_eq!(
32218            cfg.analysis.style_col_threshold, 80,
32219            "invalid threshold must not change config"
32220        );
32221    }
32222
32223    #[test]
32224    fn style_threshold_non_numeric_leaves_default() {
32225        let mut cfg = sloc_config::AppConfig::default();
32226        let mut form = blank_form();
32227        form.style_col_threshold = Some("large".to_string());
32228        apply_form_to_config(&mut cfg, &form);
32229        assert_eq!(cfg.analysis.style_col_threshold, 80);
32230    }
32231
32232    #[test]
32233    fn style_threshold_zero_leaves_default() {
32234        let mut cfg = sloc_config::AppConfig::default();
32235        let mut form = blank_form();
32236        form.style_col_threshold = Some("0".to_string());
32237        apply_form_to_config(&mut cfg, &form);
32238        assert_eq!(cfg.analysis.style_col_threshold, 80);
32239    }
32240
32241    #[test]
32242    fn style_threshold_absent_leaves_default() {
32243        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32244    }
32245
32246    // ── style_score_threshold ──
32247
32248    #[test]
32249    fn style_score_threshold_zero_when_absent() {
32250        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32251    }
32252
32253    #[test]
32254    fn style_score_threshold_set_to_valid_value() {
32255        let mut form = blank_form();
32256        form.style_score_threshold = Some("70".to_string());
32257        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32258    }
32259
32260    #[test]
32261    fn style_score_threshold_clamps_to_100_when_over() {
32262        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
32263        let mut form = blank_form();
32264        form.style_score_threshold = Some("200".to_string());
32265        assert_eq!(
32266            apply(&form).analysis.style_score_threshold,
32267            100,
32268            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32269        );
32270    }
32271
32272    // ── coverage_file ──
32273
32274    #[test]
32275    fn coverage_file_none_when_absent() {
32276        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32277    }
32278
32279    #[test]
32280    fn coverage_file_none_when_whitespace_only() {
32281        let mut form = blank_form();
32282        form.coverage_file = Some("   ".to_string());
32283        assert!(
32284            apply(&form).analysis.coverage_file.is_none(),
32285            "whitespace-only coverage_file must be treated as None"
32286        );
32287    }
32288
32289    #[test]
32290    fn coverage_file_set_when_non_empty() {
32291        let mut form = blank_form();
32292        form.coverage_file = Some("coverage/lcov.info".to_string());
32293        assert_eq!(
32294            apply(&form).analysis.coverage_file,
32295            Some(std::path::PathBuf::from("coverage/lcov.info"))
32296        );
32297    }
32298
32299    #[test]
32300    fn coverage_file_trims_whitespace() {
32301        let mut form = blank_form();
32302        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
32303        assert_eq!(
32304            apply(&form).analysis.coverage_file,
32305            Some(std::path::PathBuf::from("coverage/lcov.info"))
32306        );
32307    }
32308
32309    // ── report_title ──
32310
32311    #[test]
32312    fn report_title_unchanged_when_absent() {
32313        let original = sloc_config::AppConfig::default().reporting.report_title;
32314        assert_eq!(apply(&blank_form()).reporting.report_title, original);
32315    }
32316
32317    #[test]
32318    fn report_title_unchanged_when_whitespace_only() {
32319        let original = sloc_config::AppConfig::default().reporting.report_title;
32320        let mut form = blank_form();
32321        form.report_title = Some("   ".to_string());
32322        assert_eq!(
32323            apply(&form).reporting.report_title,
32324            original,
32325            "whitespace-only title must not overwrite the default"
32326        );
32327    }
32328
32329    #[test]
32330    fn report_title_updated_and_trimmed() {
32331        let mut form = blank_form();
32332        form.report_title = Some("  My Project  ".to_string());
32333        assert_eq!(apply(&form).reporting.report_title, "My Project");
32334    }
32335
32336    // ── report_header_footer ──
32337
32338    #[test]
32339    fn header_footer_none_when_absent() {
32340        assert!(apply(&blank_form())
32341            .reporting
32342            .report_header_footer
32343            .is_none());
32344    }
32345
32346    #[test]
32347    fn header_footer_none_when_whitespace_only() {
32348        let mut form = blank_form();
32349        form.report_header_footer = Some("  ".to_string());
32350        assert!(apply(&form).reporting.report_header_footer.is_none());
32351    }
32352
32353    #[test]
32354    fn header_footer_set_and_trimmed() {
32355        let mut form = blank_form();
32356        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
32357        assert_eq!(
32358            apply(&form).reporting.report_header_footer,
32359            Some("Confidential — Internal Use".to_string())
32360        );
32361    }
32362
32363    // ── include_globs / exclude_globs ──
32364
32365    #[test]
32366    fn include_globs_empty_when_absent() {
32367        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32368    }
32369
32370    #[test]
32371    fn include_globs_newline_separated() {
32372        let mut form = blank_form();
32373        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32374        assert_eq!(
32375            apply(&form).discovery.include_globs,
32376            vec!["src/**/*.rs", "tests/**/*.rs"]
32377        );
32378    }
32379
32380    #[test]
32381    fn exclude_globs_comma_separated() {
32382        let mut form = blank_form();
32383        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32384        assert_eq!(
32385            apply(&form).discovery.exclude_globs,
32386            vec!["vendor/**", "node_modules/**"]
32387        );
32388    }
32389
32390    #[test]
32391    fn globs_mixed_separators() {
32392        let mut form = blank_form();
32393        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32394        assert_eq!(
32395            apply(&form).discovery.exclude_globs,
32396            vec!["a/**", "b/**", "c/**"]
32397        );
32398    }
32399
32400    // ── split_patterns unit tests ──
32401
32402    #[test]
32403    fn split_patterns_none_is_empty() {
32404        assert!(split_patterns(None).is_empty());
32405    }
32406
32407    #[test]
32408    fn split_patterns_empty_string_is_empty() {
32409        assert!(split_patterns(Some("")).is_empty());
32410    }
32411
32412    #[test]
32413    fn split_patterns_whitespace_only_is_empty() {
32414        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
32415    }
32416
32417    #[test]
32418    fn split_patterns_newlines() {
32419        assert_eq!(
32420            split_patterns(Some("a/**\nb/**\nc/**")),
32421            vec!["a/**", "b/**", "c/**"]
32422        );
32423    }
32424
32425    #[test]
32426    fn split_patterns_commas() {
32427        assert_eq!(
32428            split_patterns(Some("a/**,b/**,c/**")),
32429            vec!["a/**", "b/**", "c/**"]
32430        );
32431    }
32432
32433    #[test]
32434    fn split_patterns_mixed() {
32435        assert_eq!(
32436            split_patterns(Some("a/**\nb/**,c/**")),
32437            vec!["a/**", "b/**", "c/**"]
32438        );
32439    }
32440
32441    #[test]
32442    fn split_patterns_trims_whitespace() {
32443        assert_eq!(
32444            split_patterns(Some("  a/**  \n  b/**  ")),
32445            vec!["a/**", "b/**"]
32446        );
32447    }
32448
32449    #[test]
32450    fn split_patterns_filters_empty_entries() {
32451        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32452    }
32453
32454    #[test]
32455    fn split_patterns_single_entry() {
32456        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32457    }
32458}
32459
32460#[cfg(test)]
32461mod utility_tests {
32462    use super::*;
32463    use std::net::IpAddr;
32464    use std::time::Duration;
32465
32466    // ── sanitize_project_label ────────────────────────────────────────────────
32467
32468    #[test]
32469    fn sanitize_simple_name() {
32470        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32471    }
32472
32473    #[test]
32474    fn sanitize_uppercased_lowercased() {
32475        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32476    }
32477
32478    #[test]
32479    fn sanitize_path_extracts_filename() {
32480        assert_eq!(
32481            sanitize_project_label("/home/user/my-project"),
32482            "my-project"
32483        );
32484    }
32485
32486    #[test]
32487    fn sanitize_path_uses_last_component() {
32488        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32489    }
32490
32491    #[test]
32492    fn sanitize_spaces_become_hyphens() {
32493        assert_eq!(sanitize_project_label("my project"), "my-project");
32494    }
32495
32496    #[test]
32497    fn sanitize_non_ascii_become_hyphens() {
32498        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32499    }
32500
32501    #[test]
32502    fn sanitize_all_special_chars_gives_project() {
32503        assert_eq!(sanitize_project_label("!@#$%^"), "project");
32504    }
32505
32506    #[test]
32507    fn sanitize_empty_string_gives_project() {
32508        assert_eq!(sanitize_project_label(""), "project");
32509    }
32510
32511    #[test]
32512    fn sanitize_leading_trailing_hyphens_stripped() {
32513        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32514    }
32515
32516    #[test]
32517    fn sanitize_alphanumeric_preserved() {
32518        assert_eq!(sanitize_project_label("repo123"), "repo123");
32519    }
32520
32521    #[test]
32522    fn sanitize_dots_become_hyphens() {
32523        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32524    }
32525
32526    #[test]
32527    fn sanitize_mixed_slashes_uses_filename() {
32528        // The Windows path separator — on all platforms Path::file_name still works
32529        assert_eq!(sanitize_project_label("project-name"), "project-name");
32530    }
32531
32532    // ── IpRateLimiter ─────────────────────────────────────────────────────────
32533
32534    #[test]
32535    fn rate_limiter_allows_first_request() {
32536        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32537        let ip: IpAddr = "127.0.0.1".parse().unwrap();
32538        assert!(rl.is_allowed(ip));
32539    }
32540
32541    #[test]
32542    fn rate_limiter_blocks_after_limit_reached() {
32543        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32544        let ip: IpAddr = "10.0.0.1".parse().unwrap();
32545        assert!(rl.is_allowed(ip));
32546        assert!(rl.is_allowed(ip));
32547        assert!(rl.is_allowed(ip));
32548        assert!(!rl.is_allowed(ip), "4th request must be blocked");
32549    }
32550
32551    #[test]
32552    fn rate_limiter_allows_requests_up_to_limit() {
32553        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32554        let ip: IpAddr = "10.0.0.2".parse().unwrap();
32555        for _ in 0..5 {
32556            assert!(rl.is_allowed(ip));
32557        }
32558        assert!(!rl.is_allowed(ip), "6th request must be blocked");
32559    }
32560
32561    #[test]
32562    fn rate_limiter_different_ips_are_independent() {
32563        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32564        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32565        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32566        assert!(rl.is_allowed(ip1));
32567        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32568        assert!(rl.is_allowed(ip2), "ip2 must be independent");
32569    }
32570
32571    #[test]
32572    fn rate_limiter_auth_failure_not_locked_below_threshold() {
32573        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32574        let ip: IpAddr = "10.0.0.3".parse().unwrap();
32575        rl.record_auth_failure(ip);
32576        rl.record_auth_failure(ip);
32577        assert!(
32578            !rl.is_auth_locked_out(ip),
32579            "not locked at 2 failures when threshold is 3"
32580        );
32581    }
32582
32583    #[test]
32584    fn rate_limiter_auth_failure_locked_at_threshold() {
32585        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32586        let ip: IpAddr = "10.0.0.4".parse().unwrap();
32587        rl.record_auth_failure(ip);
32588        rl.record_auth_failure(ip);
32589        rl.record_auth_failure(ip);
32590        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32591    }
32592
32593    #[test]
32594    fn rate_limiter_auth_failure_different_ips_independent() {
32595        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32596        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32597        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32598        rl.record_auth_failure(ip1);
32599        rl.record_auth_failure(ip1);
32600        assert!(rl.is_auth_locked_out(ip1));
32601        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32602    }
32603
32604    #[test]
32605    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32606        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32607        let ip: IpAddr = "127.0.0.2".parse().unwrap();
32608        for _ in 0..100 {
32609            assert!(rl.is_allowed(ip));
32610        }
32611    }
32612
32613    // ── strip_unc_prefix ──────────────────────────────────────────────────────
32614
32615    #[test]
32616    fn strip_unc_plain_path_unchanged() {
32617        let p = PathBuf::from("C:\\Users\\user\\project");
32618        let result = strip_unc_prefix(p.clone());
32619        assert_eq!(result, p);
32620    }
32621
32622    #[test]
32623    fn strip_unc_with_drive_prefix_stripped() {
32624        let p = PathBuf::from(r"\\?\C:\Users\user\project");
32625        let result = strip_unc_prefix(p);
32626        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32627    }
32628
32629    #[test]
32630    fn strip_unc_with_network_prefix_stripped() {
32631        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32632        let result = strip_unc_prefix(p);
32633        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32634    }
32635
32636    #[test]
32637    fn strip_unc_linux_path_unchanged() {
32638        let p = PathBuf::from("/home/user/project");
32639        let result = strip_unc_prefix(p.clone());
32640        assert_eq!(result, p);
32641    }
32642
32643    // ── remote_to_commit_url ──────────────────────────────────────────────────
32644
32645    #[test]
32646    fn remote_to_commit_url_github_https() {
32647        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32648        assert_eq!(
32649            url,
32650            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32651        );
32652    }
32653
32654    #[test]
32655    fn remote_to_commit_url_github_ssh() {
32656        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32657        assert_eq!(
32658            url,
32659            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32660        );
32661    }
32662
32663    #[test]
32664    fn remote_to_commit_url_gitlab_uses_dash_commit() {
32665        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32666        assert_eq!(
32667            url,
32668            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32669        );
32670    }
32671
32672    #[test]
32673    fn remote_to_commit_url_bitbucket_uses_commits() {
32674        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32675        assert_eq!(
32676            url,
32677            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32678        );
32679    }
32680
32681    #[test]
32682    fn remote_to_commit_url_unknown_scheme_returns_none() {
32683        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32684        assert!(url.is_none());
32685    }
32686
32687    #[test]
32688    fn remote_to_commit_url_ssh_gitlab() {
32689        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32690        assert!(url.is_some());
32691        let u = url.unwrap();
32692        assert!(
32693            u.contains("/-/commit/sha123"),
32694            "gitlab ssh must use /-/commit/"
32695        );
32696    }
32697
32698    // ── git_clone_dest ────────────────────────────────────────────────────────
32699
32700    #[test]
32701    fn git_clone_dest_github_url_produces_safe_name() {
32702        let dir = PathBuf::from("/tmp/clones");
32703        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32704        let name = dest.file_name().unwrap().to_string_lossy();
32705        assert!(!name.is_empty());
32706        assert!(
32707            name.chars()
32708                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32709            "clone dest must only contain safe chars, got: {name}"
32710        );
32711    }
32712
32713    #[test]
32714    fn git_clone_dest_is_inside_clones_dir() {
32715        let dir = PathBuf::from("/tmp/clones");
32716        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32717        assert!(
32718            dest.starts_with(&dir),
32719            "clone dest must be inside clones_dir"
32720        );
32721    }
32722
32723    #[test]
32724    fn git_clone_dest_truncates_to_80_chars_max() {
32725        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32726        let dir = PathBuf::from("/tmp/clones");
32727        let dest = git_clone_dest(&long_url, &dir);
32728        let name = dest.file_name().unwrap().to_string_lossy();
32729        assert!(
32730            name.len() <= 80,
32731            "clone dest name must be at most 80 chars, got {} chars: {name}",
32732            name.len()
32733        );
32734    }
32735
32736    #[test]
32737    fn git_clone_dest_special_chars_replaced_with_underscore() {
32738        let dir = PathBuf::from("/tmp/clones");
32739        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32740        let name = dest.file_name().unwrap().to_string_lossy();
32741        assert!(
32742            !name.contains('@') && !name.contains(':') && !name.contains('/'),
32743            "special chars must be replaced in clone dest, got: {name}"
32744        );
32745    }
32746
32747    #[test]
32748    fn git_clone_dest_different_urls_differ() {
32749        let dir = PathBuf::from("/tmp/clones");
32750        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32751        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32752        assert_ne!(
32753            a, b,
32754            "different repos must produce different clone dest names"
32755        );
32756    }
32757
32758    #[test]
32759    fn git_clone_dest_same_url_same_result() {
32760        let dir = PathBuf::from("/tmp/clones");
32761        let url = "https://github.com/owner/repo.git";
32762        assert_eq!(
32763            git_clone_dest(url, &dir),
32764            git_clone_dest(url, &dir),
32765            "same URL must always give same clone dest"
32766        );
32767    }
32768
32769    // ── fmt_delta ─────────────────────────────────────────────────────────────
32770
32771    #[test]
32772    fn fmt_delta_positive_has_plus_prefix() {
32773        assert_eq!(fmt_delta(5), "+5");
32774    }
32775
32776    #[test]
32777    fn fmt_delta_negative_no_plus_prefix() {
32778        assert_eq!(fmt_delta(-3), "-3");
32779    }
32780
32781    #[test]
32782    fn fmt_delta_zero() {
32783        assert_eq!(fmt_delta(0), "0");
32784    }
32785
32786    // ── delta_class ───────────────────────────────────────────────────────────
32787
32788    #[test]
32789    fn delta_class_positive_is_pos() {
32790        assert_eq!(delta_class(1), "pos");
32791    }
32792
32793    #[test]
32794    fn delta_class_negative_is_neg() {
32795        assert_eq!(delta_class(-1), "neg");
32796    }
32797
32798    #[test]
32799    fn delta_class_zero_is_zero_class() {
32800        assert_eq!(delta_class(0), "zero");
32801    }
32802
32803    // ── fmt_pct ───────────────────────────────────────────────────────────────
32804
32805    #[test]
32806    fn fmt_pct_zero_baseline_returns_em_dash() {
32807        assert_eq!(fmt_pct(100, 0), "\u{2014}");
32808    }
32809
32810    #[test]
32811    fn fmt_pct_positive_delta_has_plus_sign() {
32812        let result = fmt_pct(10, 100);
32813        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32814    }
32815
32816    #[test]
32817    fn fmt_pct_negative_delta_no_plus_sign() {
32818        let result = fmt_pct(-10, 100);
32819        assert!(!result.starts_with('+'), "unexpected + in: {result}");
32820        assert!(result.contains('%'));
32821    }
32822
32823    #[test]
32824    fn fmt_pct_near_zero_returns_pm_zero() {
32825        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32826    }
32827
32828    // ── summary_delta ─────────────────────────────────────────────────────────
32829
32830    #[test]
32831    fn summary_delta_no_prev_returns_dash_na() {
32832        let (display, class) = summary_delta(10, None);
32833        assert_eq!(display, "\u{2014}");
32834        assert_eq!(class, "na");
32835    }
32836
32837    #[test]
32838    fn summary_delta_increase_is_positive() {
32839        let (display, class) = summary_delta(15, Some(10));
32840        assert_eq!(display, "+5");
32841        assert_eq!(class, "pos");
32842    }
32843
32844    #[test]
32845    fn summary_delta_decrease_is_negative() {
32846        let (display, class) = summary_delta(5, Some(10));
32847        assert_eq!(display, "-5");
32848        assert_eq!(class, "neg");
32849    }
32850
32851    // ── nth_weekday_of_month ──────────────────────────────────────────────────
32852
32853    #[test]
32854    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32855        use chrono::Datelike;
32856        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32857        assert_eq!(d.year(), 2024);
32858        assert_eq!(d.month(), 1);
32859        assert_eq!(d.weekday(), chrono::Weekday::Mon);
32860        assert!(d.day() <= 7);
32861    }
32862
32863    #[test]
32864    fn nth_weekday_second_sunday_march_2024_is_10th() {
32865        use chrono::Datelike;
32866        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32867        assert_eq!(d.weekday(), chrono::Weekday::Sun);
32868        assert_eq!(d.month(), 3);
32869        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
32870    }
32871
32872    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
32873
32874    #[test]
32875    fn is_pacific_dst_july_is_true() {
32876        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32877        assert!(is_pacific_dst(dt), "July must be PDT");
32878    }
32879
32880    #[test]
32881    fn is_pacific_dst_january_is_false() {
32882        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32883        assert!(!is_pacific_dst(dt), "January must be PST");
32884    }
32885
32886    #[test]
32887    fn fmt_la_time_summer_shows_pdt() {
32888        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32889        let result = fmt_la_time(dt);
32890        assert!(
32891            result.ends_with("PDT"),
32892            "summer must use PDT, got: {result}"
32893        );
32894    }
32895
32896    #[test]
32897    fn fmt_la_time_winter_shows_pst() {
32898        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32899        let result = fmt_la_time(dt);
32900        assert!(
32901            result.ends_with("PST"),
32902            "winter must use PST, got: {result}"
32903        );
32904    }
32905
32906    #[test]
32907    fn fmt_la_time_meta_summer_shows_pdt() {
32908        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
32909        let result = fmt_la_time_meta(dt);
32910        assert!(
32911            result.ends_with("PDT"),
32912            "meta summer must use PDT, got: {result}"
32913        );
32914    }
32915
32916    #[test]
32917    fn fmt_la_time_meta_winter_shows_pst() {
32918        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
32919        let result = fmt_la_time_meta(dt);
32920        assert!(
32921            result.ends_with("PST"),
32922            "meta winter must use PST, got: {result}"
32923        );
32924    }
32925
32926    // ── fmt_git_date ──────────────────────────────────────────────────────────
32927
32928    #[test]
32929    fn fmt_git_date_valid_iso_returns_some() {
32930        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
32931    }
32932
32933    #[test]
32934    fn fmt_git_date_invalid_returns_none() {
32935        assert!(fmt_git_date("not-a-date").is_none());
32936    }
32937
32938    // ── format_number ─────────────────────────────────────────────────────────
32939
32940    #[test]
32941    fn format_number_zero() {
32942        assert_eq!(format_number(0), "0");
32943    }
32944
32945    #[test]
32946    fn format_number_three_digits_no_comma() {
32947        assert_eq!(format_number(999), "999");
32948    }
32949
32950    #[test]
32951    fn format_number_four_digits_has_comma() {
32952        assert_eq!(format_number(1000), "1,000");
32953    }
32954
32955    #[test]
32956    fn format_number_seven_digits_two_commas() {
32957        assert_eq!(format_number(1_234_567), "1,234,567");
32958    }
32959
32960    #[test]
32961    fn format_number_one_million() {
32962        assert_eq!(format_number(1_000_000), "1,000,000");
32963    }
32964
32965    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
32966
32967    #[test]
32968    fn badge_text_px_empty_is_zero() {
32969        assert_eq!(badge_text_px(""), 0);
32970    }
32971
32972    #[test]
32973    fn badge_text_px_narrow_chars_smaller_than_normal() {
32974        assert!(
32975            badge_text_px("if") < badge_text_px("ab"),
32976            "'if' must be narrower than 'ab'"
32977        );
32978    }
32979
32980    #[test]
32981    fn badge_text_px_m_is_wider_than_a() {
32982        assert!(
32983            badge_text_px("m") > badge_text_px("a"),
32984            "'m' must be wider than 'a'"
32985        );
32986    }
32987
32988    #[test]
32989    fn render_badge_svg_contains_label_and_value() {
32990        let svg = render_badge_svg("coverage", "95%", "#4c1");
32991        assert!(svg.contains("coverage") && svg.contains("95%"));
32992    }
32993
32994    #[test]
32995    fn render_badge_svg_contains_color() {
32996        let svg = render_badge_svg("sloc", "12K", "#e05d44");
32997        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
32998    }
32999
33000    #[test]
33001    fn render_badge_svg_escapes_ampersand_in_label() {
33002        let svg = render_badge_svg("test&label", "ok", "#4c1");
33003        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33004    }
33005
33006    // ── build_pdf_filename ────────────────────────────────────────────────────
33007
33008    #[test]
33009    fn build_pdf_filename_slugifies_title() {
33010        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33011        assert!(
33012            name.starts_with("my_project_report_")
33013                && std::path::Path::new(&name)
33014                    .extension()
33015                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33016        );
33017    }
33018
33019    #[test]
33020    fn build_pdf_filename_uses_last_run_id_segment() {
33021        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33022        assert!(name.contains("ABCD"), "must use last segment of run_id");
33023    }
33024
33025    #[test]
33026    fn build_pdf_filename_empty_title_uses_report_prefix() {
33027        let name = build_pdf_filename("", "abc-def-9999");
33028        assert!(
33029            name.starts_with("report_")
33030                && std::path::Path::new(&name)
33031                    .extension()
33032                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33033        );
33034    }
33035
33036    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33037
33038    #[test]
33039    fn swap_chart_js_replaces_inline_block() {
33040        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33041        let result = swap_inline_chart_js_for_static(html.to_string());
33042        assert!(result.contains(r#"src="/static/chart-report.js""#));
33043        assert!(!result.contains("inline source"));
33044    }
33045
33046    #[test]
33047    fn swap_chart_js_no_head_returns_unchanged() {
33048        let html = "<body>no head here</body>";
33049        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33050    }
33051
33052    #[test]
33053    fn swap_chart_js_no_script_in_head_unchanged() {
33054        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33055        let result = swap_inline_chart_js_for_static(html.to_string());
33056        assert!(!result.contains("chart-report.js"));
33057    }
33058
33059    // ── patch_html_nonce ──────────────────────────────────────────────────────
33060
33061    #[test]
33062    fn patch_html_nonce_replaces_old_nonce() {
33063        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33064        let result = patch_html_nonce(html, "new-nonce-456");
33065        assert!(result.contains(r#"nonce="new-nonce-456""#));
33066        assert!(!result.contains("old-nonce-123"));
33067    }
33068
33069    #[test]
33070    fn patch_html_nonce_injects_into_bare_style() {
33071        let html = "<style>body{color:red;}</style>";
33072        let result = patch_html_nonce(html, "fresh-nonce");
33073        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33074    }
33075
33076    #[test]
33077    fn patch_html_nonce_injects_into_bare_script() {
33078        let html = "<script>console.log(1);</script>";
33079        let result = patch_html_nonce(html, "abc");
33080        assert!(result.contains(r#"<script nonce="abc">"#));
33081    }
33082
33083    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33084
33085    #[test]
33086    fn is_html_report_file_result_html_matches() {
33087        let dir = tempfile::tempdir().unwrap();
33088        let path = dir.path().join("result_20240101.html");
33089        std::fs::write(&path, b"<html></html>").unwrap();
33090        assert!(is_html_report_file(&path));
33091    }
33092
33093    #[test]
33094    fn is_html_report_file_report_html_matches() {
33095        let dir = tempfile::tempdir().unwrap();
33096        let path = dir.path().join("report_abc.html");
33097        std::fs::write(&path, b"<html></html>").unwrap();
33098        assert!(is_html_report_file(&path));
33099    }
33100
33101    #[test]
33102    fn is_html_report_file_index_html_does_not_match() {
33103        let dir = tempfile::tempdir().unwrap();
33104        let path = dir.path().join("index.html");
33105        std::fs::write(&path, b"<html></html>").unwrap();
33106        assert!(!is_html_report_file(&path));
33107    }
33108
33109    #[test]
33110    fn is_html_report_file_nonexistent_returns_false() {
33111        assert!(!is_html_report_file(Path::new(
33112            "/nonexistent/result_xyz.html"
33113        )));
33114    }
33115
33116    #[test]
33117    fn find_html_report_in_dir_finds_result_html() {
33118        let dir = tempfile::tempdir().unwrap();
33119        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33120        assert!(find_html_report_in_dir(dir.path()).is_some());
33121    }
33122
33123    #[test]
33124    fn find_html_report_in_dir_empty_returns_none() {
33125        let dir = tempfile::tempdir().unwrap();
33126        assert!(find_html_report_in_dir(dir.path()).is_none());
33127    }
33128
33129    #[test]
33130    fn find_html_report_in_tree_finds_in_subdir() {
33131        let dir = tempfile::tempdir().unwrap();
33132        let subdir = dir.path().join("run-001");
33133        std::fs::create_dir_all(&subdir).unwrap();
33134        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33135        assert!(find_html_report_in_tree(dir.path()).is_some());
33136    }
33137
33138    // ── derive_project_label ──────────────────────────────────────────────────
33139
33140    #[test]
33141    fn derive_project_label_with_git_repo_and_ref() {
33142        let label = derive_project_label(
33143            Some("https://github.com/owner/my-repo.git"),
33144            Some("main"),
33145            "/fallback/path",
33146        );
33147        assert!(!label.is_empty(), "label must not be empty");
33148        assert!(
33149            label.contains("my") || label.contains("repo"),
33150            "got: {label}"
33151        );
33152    }
33153
33154    #[test]
33155    fn derive_project_label_fallback_to_path() {
33156        let label = derive_project_label(None, None, "/path/to/myproject");
33157        assert_eq!(label, "myproject");
33158    }
33159
33160    #[test]
33161    fn derive_project_label_empty_git_fields_use_path() {
33162        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33163        assert_eq!(label, "cool-app");
33164    }
33165
33166    // ── derive_file_stem ──────────────────────────────────────────────────────
33167
33168    #[test]
33169    fn derive_file_stem_with_commit_appends_sha() {
33170        assert_eq!(
33171            derive_file_stem("myproject", Some("a1b2c3")),
33172            "myproject_a1b2c3"
33173        );
33174    }
33175
33176    #[test]
33177    fn derive_file_stem_without_commit_returns_label() {
33178        assert_eq!(derive_file_stem("myproject", None), "myproject");
33179    }
33180
33181    #[test]
33182    fn derive_file_stem_empty_commit_returns_label() {
33183        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33184    }
33185
33186    // ── split_patterns ────────────────────────────────────────────────────────
33187
33188    #[test]
33189    fn split_patterns_none_is_empty() {
33190        assert!(split_patterns(None).is_empty());
33191    }
33192
33193    #[test]
33194    fn split_patterns_empty_string_is_empty() {
33195        assert!(split_patterns(Some("")).is_empty());
33196    }
33197
33198    #[test]
33199    fn split_patterns_comma_separated() {
33200        assert_eq!(
33201            split_patterns(Some("foo,bar,baz")),
33202            vec!["foo", "bar", "baz"]
33203        );
33204    }
33205
33206    #[test]
33207    fn split_patterns_newline_separated() {
33208        assert_eq!(
33209            split_patterns(Some("foo\nbar\nbaz")),
33210            vec!["foo", "bar", "baz"]
33211        );
33212    }
33213
33214    #[test]
33215    fn split_patterns_trims_whitespace() {
33216        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
33217    }
33218
33219    // ── make_git_label ────────────────────────────────────────────────────────
33220
33221    #[test]
33222    fn make_git_label_empty_repo_empty_result() {
33223        assert_eq!(make_git_label("", "main"), "");
33224    }
33225
33226    #[test]
33227    fn make_git_label_empty_ref_empty_result() {
33228        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33229    }
33230
33231    #[test]
33232    fn make_git_label_basic_format() {
33233        assert_eq!(
33234            make_git_label("https://github.com/owner/my-repo.git", "main"),
33235            "my-repo_at_main_sloc"
33236        );
33237    }
33238
33239    #[test]
33240    fn make_git_label_slash_in_ref_replaced() {
33241        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33242        assert!(
33243            !label.contains('/'),
33244            "slash in ref must be replaced: {label}"
33245        );
33246    }
33247
33248    // ── format_dir_size ───────────────────────────────────────────────────────
33249
33250    #[test]
33251    fn format_dir_size_bytes() {
33252        assert_eq!(format_dir_size(500), "500 B");
33253    }
33254
33255    #[test]
33256    fn format_dir_size_kilobytes() {
33257        assert_eq!(format_dir_size(2048), "2 KB");
33258    }
33259
33260    #[test]
33261    fn format_dir_size_megabytes() {
33262        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33263    }
33264
33265    #[test]
33266    fn format_dir_size_gigabytes() {
33267        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33268    }
33269
33270    #[test]
33271    fn format_dir_size_zero() {
33272        assert_eq!(format_dir_size(0), "0 B");
33273    }
33274
33275    // ── civil_from_days ───────────────────────────────────────────────────────
33276
33277    #[test]
33278    fn civil_from_days_epoch() {
33279        assert_eq!(civil_from_days(0), (1970, 1, 1));
33280    }
33281
33282    #[test]
33283    fn civil_from_days_one_year_later() {
33284        assert_eq!(civil_from_days(365), (1971, 1, 1));
33285    }
33286
33287    #[test]
33288    fn civil_from_days_31_days_is_feb_1_1970() {
33289        assert_eq!(civil_from_days(31), (1970, 2, 1));
33290    }
33291
33292    // ── format_system_time ────────────────────────────────────────────────────
33293
33294    #[test]
33295    fn format_system_time_unix_epoch_formats_correctly() {
33296        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33297    }
33298
33299    #[test]
33300    fn format_system_time_31_days_after_epoch() {
33301        let t = UNIX_EPOCH + Duration::from_hours(744);
33302        assert_eq!(format_system_time(t), "1970-02-01 00:00");
33303    }
33304
33305    #[test]
33306    fn format_system_time_before_epoch_returns_dash() {
33307        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33308            assert_eq!(format_system_time(before), "-");
33309        }
33310    }
33311
33312    // ── detect_language_name ──────────────────────────────────────────────────
33313
33314    #[test]
33315    fn detect_language_name_dot_c() {
33316        assert_eq!(detect_language_name("main.c"), Some("C"));
33317    }
33318
33319    #[test]
33320    fn detect_language_name_dot_h() {
33321        assert_eq!(detect_language_name("defs.h"), Some("C"));
33322    }
33323
33324    #[test]
33325    fn detect_language_name_dot_cpp() {
33326        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33327    }
33328
33329    #[test]
33330    fn detect_language_name_dot_py() {
33331        assert_eq!(detect_language_name("script.py"), Some("Python"));
33332    }
33333
33334    #[test]
33335    fn detect_language_name_dot_ps1() {
33336        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33337    }
33338
33339    #[test]
33340    fn detect_language_name_dot_cs() {
33341        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33342    }
33343
33344    #[test]
33345    fn detect_language_name_dot_sh() {
33346        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33347    }
33348
33349    #[test]
33350    fn detect_language_name_unknown_txt() {
33351        assert_eq!(detect_language_name("notes.txt"), None);
33352    }
33353
33354    // ── language_icon_file ────────────────────────────────────────────────────
33355
33356    #[test]
33357    fn language_icon_file_c() {
33358        assert_eq!(language_icon_file("C"), Some("c.png"));
33359    }
33360
33361    #[test]
33362    fn language_icon_file_python() {
33363        assert_eq!(language_icon_file("Python"), Some("python.png"));
33364    }
33365
33366    #[test]
33367    fn language_icon_file_dockerfile() {
33368        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33369    }
33370
33371    #[test]
33372    fn language_icon_file_rust_is_none() {
33373        assert!(language_icon_file("Rust").is_none());
33374    }
33375
33376    #[test]
33377    fn language_icon_file_unknown_is_none() {
33378        assert!(language_icon_file("Fortran").is_none());
33379    }
33380
33381    // ── language_inline_svg ───────────────────────────────────────────────────
33382
33383    #[test]
33384    fn language_inline_svg_rust_is_svg() {
33385        let svg = language_inline_svg("Rust").unwrap();
33386        assert!(svg.starts_with("<svg"));
33387    }
33388
33389    #[test]
33390    fn language_inline_svg_typescript_is_some() {
33391        assert!(language_inline_svg("TypeScript").is_some());
33392    }
33393
33394    #[test]
33395    fn language_inline_svg_unknown_is_none() {
33396        assert!(language_inline_svg("Fortran").is_none());
33397    }
33398
33399    // ── classify_preview_file ─────────────────────────────────────────────────
33400
33401    #[test]
33402    fn classify_preview_file_c_supported() {
33403        assert!(matches!(
33404            classify_preview_file("main.c"),
33405            PreviewKind::Supported
33406        ));
33407    }
33408
33409    #[test]
33410    fn classify_preview_file_python_supported() {
33411        assert!(matches!(
33412            classify_preview_file("script.py"),
33413            PreviewKind::Supported
33414        ));
33415    }
33416
33417    #[test]
33418    fn classify_preview_file_png_skipped() {
33419        assert!(matches!(
33420            classify_preview_file("image.png"),
33421            PreviewKind::Skipped
33422        ));
33423    }
33424
33425    #[test]
33426    fn classify_preview_file_zip_skipped() {
33427        assert!(matches!(
33428            classify_preview_file("archive.zip"),
33429            PreviewKind::Skipped
33430        ));
33431    }
33432
33433    #[test]
33434    fn classify_preview_file_min_js_skipped() {
33435        assert!(matches!(
33436            classify_preview_file("bundle.min.js"),
33437            PreviewKind::Skipped
33438        ));
33439    }
33440
33441    #[test]
33442    fn classify_preview_file_rs_unsupported() {
33443        assert!(matches!(
33444            classify_preview_file("main.rs"),
33445            PreviewKind::Unsupported
33446        ));
33447    }
33448
33449    // ── preview_relative_path ─────────────────────────────────────────────────
33450
33451    #[test]
33452    fn preview_relative_path_strips_root() {
33453        let root = PathBuf::from("/project");
33454        let path = PathBuf::from("/project/src/main.c");
33455        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33456    }
33457
33458    #[test]
33459    fn preview_relative_path_unrooted_includes_filename() {
33460        let root = PathBuf::from("/other");
33461        let path = PathBuf::from("/project/src/main.c");
33462        let result = preview_relative_path(&root, &path);
33463        assert!(result.contains("main.c"));
33464    }
33465
33466    #[test]
33467    fn preview_relative_path_uses_forward_slashes() {
33468        let root = PathBuf::from("/project");
33469        let path = PathBuf::from("/project/a/b/c.py");
33470        assert!(!preview_relative_path(&root, &path).contains('\\'));
33471    }
33472
33473    // ── wildcard_match ────────────────────────────────────────────────────────
33474
33475    #[test]
33476    fn wildcard_match_exact_equal() {
33477        assert!(wildcard_match("foo", "foo"));
33478    }
33479
33480    #[test]
33481    fn wildcard_match_exact_mismatch() {
33482        assert!(!wildcard_match("foo", "bar"));
33483    }
33484
33485    #[test]
33486    fn wildcard_match_star_suffix() {
33487        assert!(wildcard_match("*.rs", "main.rs"));
33488    }
33489
33490    #[test]
33491    fn wildcard_match_star_middle_requires_suffix() {
33492        assert!(!wildcard_match("a*b", "ac"));
33493    }
33494
33495    #[test]
33496    fn wildcard_match_question_mark_single_char() {
33497        assert!(wildcard_match("f?o", "foo"));
33498    }
33499
33500    #[test]
33501    fn wildcard_match_double_star_nested() {
33502        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33503    }
33504
33505    #[test]
33506    fn wildcard_match_star_directory_entry() {
33507        assert!(wildcard_match("vendor/*", "vendor/crate"));
33508    }
33509
33510    #[test]
33511    fn wildcard_match_no_cross_prefix() {
33512        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33513    }
33514
33515    // ── should_skip_preview_directory ────────────────────────────────────────
33516
33517    #[test]
33518    fn should_skip_empty_relative_is_false() {
33519        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33520    }
33521
33522    #[test]
33523    fn should_skip_matching_pattern() {
33524        assert!(should_skip_preview_directory(
33525            "vendor",
33526            &["vendor".to_string()]
33527        ));
33528    }
33529
33530    #[test]
33531    fn should_skip_non_matching() {
33532        assert!(!should_skip_preview_directory(
33533            "src",
33534            &["vendor".to_string()]
33535        ));
33536    }
33537
33538    #[test]
33539    fn should_skip_wildcard_prefix() {
33540        assert!(should_skip_preview_directory(
33541            "target/debug",
33542            &["target*".to_string()]
33543        ));
33544    }
33545
33546    // ── should_include_preview_file ───────────────────────────────────────────
33547
33548    #[test]
33549    fn should_include_empty_relative_always_true() {
33550        assert!(should_include_preview_file("", &[], &[]));
33551    }
33552
33553    #[test]
33554    fn should_include_no_patterns_includes_all() {
33555        assert!(should_include_preview_file("src/main.c", &[], &[]));
33556    }
33557
33558    #[test]
33559    fn should_include_excluded_by_pattern() {
33560        assert!(!should_include_preview_file(
33561            "vendor/lib.c",
33562            &[],
33563            &["vendor/*".to_string()]
33564        ));
33565    }
33566
33567    #[test]
33568    fn should_include_include_pattern_filters() {
33569        assert!(!should_include_preview_file(
33570            "tests/test_foo.c",
33571            &["src/*".to_string()],
33572            &[]
33573        ));
33574    }
33575
33576    // ── escape_html ───────────────────────────────────────────────────────────
33577
33578    #[test]
33579    fn escape_html_ampersand() {
33580        assert_eq!(escape_html("a&b"), "a&amp;b");
33581    }
33582
33583    #[test]
33584    fn escape_html_angle_brackets() {
33585        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
33586    }
33587
33588    #[test]
33589    fn escape_html_double_quote() {
33590        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
33591    }
33592
33593    #[test]
33594    fn escape_html_single_quote() {
33595        assert_eq!(escape_html("it's"), "it&#39;s");
33596    }
33597
33598    #[test]
33599    fn escape_html_plain_text_unchanged() {
33600        assert_eq!(escape_html("hello world"), "hello world");
33601    }
33602
33603    // ── sum_added / removed / unmodified code lines ───────────────────────────
33604
33605    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33606        sloc_core::ScanComparison {
33607            summary: sloc_core::SummaryDelta {
33608                baseline_run_id: "base".to_string(),
33609                current_run_id: "curr".to_string(),
33610                baseline_timestamp: chrono::Utc::now(),
33611                current_timestamp: chrono::Utc::now(),
33612                baseline_files: 4,
33613                current_files: 4,
33614                files_analyzed_delta: 0,
33615                baseline_code: 330,
33616                current_code: 400,
33617                code_lines_delta: 70,
33618                baseline_comments: 0,
33619                current_comments: 0,
33620                comment_lines_delta: 0,
33621                blank_lines_delta: 0,
33622                total_lines_delta: 70,
33623                coverage_lines_hit_delta: None,
33624                coverage_line_pct_delta: None,
33625                baseline_coverage_line_pct: None,
33626                current_coverage_line_pct: None,
33627            },
33628            file_deltas: vec![
33629                sloc_core::FileDelta {
33630                    relative_path: "added.rs".to_string(),
33631                    language: Some("Rust".to_string()),
33632                    status: FileChangeStatus::Added,
33633                    baseline_code: 0,
33634                    current_code: 100,
33635                    code_delta: 100,
33636                    baseline_comment: 0,
33637                    current_comment: 0,
33638                    comment_delta: 0,
33639                    baseline_blank: 0,
33640                    current_blank: 0,
33641                    blank_delta: 0,
33642                    total_delta: 100,
33643                },
33644                sloc_core::FileDelta {
33645                    relative_path: "removed.rs".to_string(),
33646                    language: Some("Rust".to_string()),
33647                    status: FileChangeStatus::Removed,
33648                    baseline_code: 50,
33649                    current_code: 0,
33650                    code_delta: -50,
33651                    baseline_comment: 0,
33652                    current_comment: 0,
33653                    comment_delta: 0,
33654                    baseline_blank: 0,
33655                    current_blank: 0,
33656                    blank_delta: 0,
33657                    total_delta: -50,
33658                },
33659                sloc_core::FileDelta {
33660                    relative_path: "modified.rs".to_string(),
33661                    language: Some("Rust".to_string()),
33662                    status: FileChangeStatus::Modified,
33663                    baseline_code: 80,
33664                    current_code: 100,
33665                    code_delta: 20,
33666                    baseline_comment: 0,
33667                    current_comment: 0,
33668                    comment_delta: 0,
33669                    baseline_blank: 0,
33670                    current_blank: 0,
33671                    blank_delta: 0,
33672                    total_delta: 20,
33673                },
33674                sloc_core::FileDelta {
33675                    relative_path: "unchanged.rs".to_string(),
33676                    language: Some("Rust".to_string()),
33677                    status: FileChangeStatus::Unchanged,
33678                    baseline_code: 200,
33679                    current_code: 200,
33680                    code_delta: 0,
33681                    baseline_comment: 0,
33682                    current_comment: 0,
33683                    comment_delta: 0,
33684                    baseline_blank: 0,
33685                    current_blank: 0,
33686                    blank_delta: 0,
33687                    total_delta: 0,
33688                },
33689            ],
33690            files_added: 1,
33691            files_removed: 1,
33692            files_modified: 1,
33693            files_unchanged: 1,
33694        }
33695    }
33696
33697    #[test]
33698    fn sum_added_counts_added_and_positive_modified() {
33699        let cmp = make_mixed_scan_comparison();
33700        assert_eq!(sum_added_code_lines(&cmp), 120);
33701    }
33702
33703    #[test]
33704    fn sum_removed_counts_removed_baseline() {
33705        let cmp = make_mixed_scan_comparison();
33706        assert_eq!(sum_removed_code_lines(&cmp), 50);
33707    }
33708
33709    #[test]
33710    fn sum_unmodified_counts_unchanged_files() {
33711        let cmp = make_mixed_scan_comparison();
33712        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33713    }
33714
33715    // ── detect_coverage_tool ──────────────────────────────────────────────────
33716
33717    #[test]
33718    fn detect_coverage_tool_rust_project() {
33719        let dir = tempfile::tempdir().unwrap();
33720        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33721        let (tool, cmd) = detect_coverage_tool(dir.path());
33722        assert_eq!(tool, Some("cargo-llvm-cov"));
33723        assert!(cmd.is_some());
33724    }
33725
33726    #[test]
33727    fn detect_coverage_tool_java_gradle() {
33728        let dir = tempfile::tempdir().unwrap();
33729        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33730        let (tool, _) = detect_coverage_tool(dir.path());
33731        assert_eq!(tool, Some("jacoco"));
33732    }
33733
33734    #[test]
33735    fn detect_coverage_tool_python_pyproject() {
33736        let dir = tempfile::tempdir().unwrap();
33737        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33738        let (tool, _) = detect_coverage_tool(dir.path());
33739        assert_eq!(tool, Some("pytest-cov"));
33740    }
33741
33742    #[test]
33743    fn detect_coverage_tool_unknown_project() {
33744        let dir = tempfile::tempdir().unwrap();
33745        let (tool, cmd) = detect_coverage_tool(dir.path());
33746        assert!(tool.is_none() && cmd.is_none());
33747    }
33748
33749    // ── sanitize_path_str / display_path ─────────────────────────────────────
33750
33751    #[test]
33752    fn sanitize_path_str_unc_drive_stripped() {
33753        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33754    }
33755
33756    #[test]
33757    fn sanitize_path_str_unc_network_stripped() {
33758        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33759    }
33760
33761    #[test]
33762    fn sanitize_path_str_plain_path_unchanged() {
33763        assert_eq!(
33764            sanitize_path_str("/home/user/project"),
33765            "/home/user/project"
33766        );
33767    }
33768
33769    #[test]
33770    fn display_path_plain_linux_unchanged() {
33771        assert_eq!(
33772            display_path(Path::new("/home/user/project")),
33773            "/home/user/project"
33774        );
33775    }
33776
33777    #[test]
33778    fn display_path_unc_drive_stripped() {
33779        let result = display_path(Path::new(r"\\?\C:\Users\user"));
33780        assert_eq!(result, r"C:\Users\user");
33781    }
33782
33783    #[test]
33784    fn display_path_unc_network_stripped() {
33785        let result = display_path(Path::new(r"\\?\UNC\server\share"));
33786        assert_eq!(result, r"\\server\share");
33787    }
33788}
33789
33790#[cfg(test)]
33791mod coverage_boost_unit_tests {
33792    use super::*;
33793    use std::path::{Path, PathBuf};
33794
33795    // Both scenarios live in one test (sequential, under a Tokio runtime) because
33796    // load_runtime_security_config spawns a pruning task and mutates process-global
33797    // env vars — parallel sub-tests would race on both.
33798    #[tokio::test]
33799    async fn runtime_security_config_scenarios() {
33800        std::env::remove_var("SLOC_API_KEYS");
33801        std::env::remove_var("SLOC_API_KEY");
33802        std::env::remove_var("SLOC_TLS_CERT");
33803        std::env::remove_var("SLOC_TLS_KEY");
33804        std::env::remove_var("SLOC_TRUST_PROXY");
33805        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33806        let cfg = load_runtime_security_config(false);
33807        assert!(cfg.api_keys.is_empty());
33808        assert!(!cfg.tls_enabled);
33809        assert!(!cfg.trust_proxy);
33810
33811        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33812        std::env::set_var("SLOC_TRUST_PROXY", "1");
33813        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33814        std::env::set_var("SLOC_RATE_LIMIT", "250");
33815        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33816        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33817        let cfg = load_runtime_security_config(true);
33818        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33819        assert!(cfg.trust_proxy);
33820        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33821        std::env::remove_var("SLOC_API_KEYS");
33822        std::env::remove_var("SLOC_TRUST_PROXY");
33823        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33824        std::env::remove_var("SLOC_RATE_LIMIT");
33825        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33826        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33827    }
33828
33829    #[test]
33830    fn cors_layer_builds_both_modes() {
33831        let _ = build_cors_layer(true);
33832        let _ = build_cors_layer(false);
33833    }
33834
33835    #[test]
33836    fn primary_lan_ip_callable() {
33837        // May be Some or None depending on the host; both are valid.
33838        let _ = primary_lan_ip();
33839    }
33840
33841    #[test]
33842    fn safe_redirect_allows_relative_rejects_absolute() {
33843        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33844        assert_eq!(safe_redirect("https://evil.example/x"), "/");
33845        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33846        assert_eq!(default_redirect(), "/view-reports");
33847    }
33848
33849    #[test]
33850    fn tarball_size_caps_env_override() {
33851        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33852        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33853        let (c, d) = parse_tarball_size_caps();
33854        assert_eq!(c, 1024 * 1024);
33855        assert_eq!(d, 2 * 1024 * 1024);
33856        std::env::remove_var("SLOC_MAX_TARBALL_MB");
33857        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33858        let (c2, _) = parse_tarball_size_caps();
33859        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33860    }
33861
33862    #[test]
33863    fn upload_path_helpers() {
33864        let base = upload_base_dir();
33865        let staged = upload_staging_path("abc123");
33866        assert!(staged.starts_with(&base));
33867        assert!(
33868            is_upload_tmp_path(&staged),
33869            "staging path is an upload tmp path"
33870        );
33871        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
33872    }
33873
33874    #[test]
33875    fn git_clones_dir_env_override() {
33876        std::env::remove_var("SLOC_GIT_CLONES_DIR");
33877        let def = resolve_git_clones_dir(Path::new("/out"));
33878        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
33879        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
33880        assert_eq!(
33881            resolve_git_clones_dir(Path::new("/out")),
33882            PathBuf::from("/custom/clones")
33883        );
33884        std::env::remove_var("SLOC_GIT_CLONES_DIR");
33885    }
33886
33887    #[test]
33888    fn html_report_file_detection() {
33889        let dir = std::env::temp_dir().join("sloc_html_detect");
33890        let _ = std::fs::create_dir_all(&dir);
33891        let good = dir.join("report_x.html");
33892        std::fs::write(&good, "<html></html>").unwrap();
33893        let bad = dir.join("notes.txt");
33894        std::fs::write(&bad, "x").unwrap();
33895        assert!(is_html_report_file(&good));
33896        assert!(!is_html_report_file(&bad));
33897        assert!(find_html_report_in_dir(&dir).is_some());
33898        let _ = std::fs::remove_dir_all(&dir);
33899    }
33900
33901    #[test]
33902    fn multi_delta_class_and_format() {
33903        assert_eq!(multi_delta_class(5), "pos");
33904        assert_eq!(multi_delta_class(-5), "neg");
33905        assert_eq!(multi_delta_class(0), "zero");
33906        assert_eq!(multi_fmt_delta(3), "+3");
33907        assert_eq!(multi_fmt_delta(-3), "-3");
33908        assert_eq!(multi_fmt_delta(0), "0");
33909    }
33910
33911    #[test]
33912    fn git_clone_dest_sanitizes() {
33913        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
33914        assert!(dest.starts_with("/clones"));
33915        let name = dest.file_name().unwrap().to_str().unwrap();
33916        assert!(name
33917            .chars()
33918            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
33919    }
33920}
33921
33922#[cfg(test)]
33923mod tests_private {
33924    use super::*;
33925    use std::io::Read;
33926
33927    #[test]
33928    fn size_limit_reader_zero_remaining_returns_error() {
33929        let data = b"hello world";
33930        let mut reader = SizeLimitReader {
33931            inner: &data[..],
33932            remaining: 0,
33933        };
33934        let mut buf = [0u8; 4];
33935        assert!(reader.read(&mut buf).is_err());
33936    }
33937
33938    #[test]
33939    fn size_limit_reader_counts_bytes() {
33940        let data = b"hello world";
33941        let mut reader = SizeLimitReader {
33942            inner: &data[..],
33943            remaining: 5,
33944        };
33945        let mut buf = [0u8; 4];
33946        let n = reader.read(&mut buf).unwrap();
33947        assert_eq!(n, 4);
33948        assert_eq!(reader.remaining, 1);
33949    }
33950
33951    #[test]
33952    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
33953        let uuid = "12345678-1234-1234-1234-123456789012";
33954        let (id, path) = resolve_or_create_staging(Some(uuid));
33955        assert_eq!(id, uuid);
33956        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
33957    }
33958
33959    #[test]
33960    fn resolve_or_create_staging_with_none_creates_new() {
33961        let (id1, _) = resolve_or_create_staging(None);
33962        let (id2, _) = resolve_or_create_staging(None);
33963        assert_ne!(id1, id2);
33964    }
33965
33966    #[test]
33967    fn resolve_or_create_staging_with_path_separator_creates_new() {
33968        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
33969        let (id, _) = resolve_or_create_staging(Some("has/slash"));
33970        assert_ne!(id, "has/slash");
33971    }
33972
33973    #[test]
33974    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
33975        use std::net::IpAddr;
33976        use std::str::FromStr;
33977        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
33978        let ip = IpAddr::from_str("192.168.1.1").unwrap();
33979        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
33980    }
33981
33982    #[test]
33983    fn is_auth_locked_out_expired_entry_removed() {
33984        use std::net::IpAddr;
33985        use std::str::FromStr;
33986        let limiter = IpRateLimiter::new(
33987            Duration::from_mins(1),
33988            100,
33989            1, // 1 failure triggers lockout
33990            Duration::from_millis(1),
33991        );
33992        let ip = IpAddr::from_str("192.168.1.2").unwrap();
33993        limiter.record_auth_failure(ip);
33994        // Wait for the 1ms window to expire
33995        std::thread::sleep(Duration::from_millis(10));
33996        // Expired entry should be removed, returning false
33997        assert!(!limiter.is_auth_locked_out(ip));
33998    }
33999
34000    #[test]
34001    fn is_auth_locked_out_within_window_returns_true() {
34002        use std::net::IpAddr;
34003        use std::str::FromStr;
34004        let limiter = IpRateLimiter::new(
34005            Duration::from_mins(1),
34006            100,
34007            2, // 2 failures triggers lockout
34008            Duration::from_hours(1),
34009        );
34010        let ip = IpAddr::from_str("192.168.1.3").unwrap();
34011        limiter.record_auth_failure(ip);
34012        limiter.record_auth_failure(ip);
34013        assert!(limiter.is_auth_locked_out(ip));
34014    }
34015
34016    // ── output_folder_hint ───────────────────────────────────────────────────────
34017
34018    #[test]
34019    fn output_folder_hint_strips_json_subdir() {
34020        use std::path::Path;
34021        let path = Path::new("/output/scan1/json/result.json");
34022        let hint = output_folder_hint(path);
34023        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34024    }
34025
34026    #[test]
34027    fn output_folder_hint_strips_html_subdir() {
34028        use std::path::Path;
34029        let path = Path::new("/output/scan1/html/report.html");
34030        let hint = output_folder_hint(path);
34031        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34032    }
34033
34034    #[test]
34035    fn output_folder_hint_strips_pdf_subdir() {
34036        use std::path::Path;
34037        let path = Path::new("/output/scan1/pdf/report.pdf");
34038        let hint = output_folder_hint(path);
34039        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34040    }
34041
34042    #[test]
34043    fn output_folder_hint_strips_excel_subdir() {
34044        use std::path::Path;
34045        let path = Path::new("/output/scan1/excel/report.xlsx");
34046        let hint = output_folder_hint(path);
34047        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34048    }
34049
34050    #[test]
34051    fn output_folder_hint_flat_layout_returns_direct_parent() {
34052        use std::path::Path;
34053        let path = Path::new("/output/scan1/result.json");
34054        let hint = output_folder_hint(path);
34055        assert!(
34056            hint.ends_with("scan1"),
34057            "expected direct parent, got: {hint}"
34058        );
34059    }
34060
34061    #[test]
34062    fn output_folder_hint_other_subdir_name_not_stripped() {
34063        use std::path::Path;
34064        // "data" is not one of the named artifact subdirs — parent is kept as-is
34065        let path = Path::new("/output/scan1/data/result.json");
34066        let hint = output_folder_hint(path);
34067        assert!(
34068            hint.ends_with("data"),
34069            "non-artifact subdir must not be stripped, got: {hint}"
34070        );
34071    }
34072
34073    // ── find_file_by_ext ─────────────────────────────────────────────────────────
34074
34075    #[test]
34076    fn find_file_by_ext_finds_matching_file() {
34077        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34078        let _ = fs::create_dir_all(&dir);
34079        let f = dir.join("report.pdf");
34080        let _ = fs::write(&f, b"dummy");
34081        let result = find_file_by_ext(&dir, "pdf");
34082        assert!(result.is_some(), "expected to find report.pdf");
34083        let _ = fs::remove_dir_all(&dir);
34084    }
34085
34086    #[test]
34087    fn find_file_by_ext_returns_none_for_missing_ext() {
34088        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34089        let _ = fs::create_dir_all(&dir);
34090        let f = dir.join("report.json");
34091        let _ = fs::write(&f, b"{}");
34092        let result = find_file_by_ext(&dir, "pdf");
34093        assert!(result.is_none());
34094        let _ = fs::remove_dir_all(&dir);
34095    }
34096
34097    #[test]
34098    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34099        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34100        assert!(find_file_by_ext(dir, "json").is_none());
34101    }
34102
34103    // ── collect_result_json_candidates ───────────────────────────────────────────
34104
34105    #[test]
34106    fn collect_result_json_candidates_flat_root() {
34107        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34108        let _ = fs::create_dir_all(&root);
34109        let _ = fs::write(root.join("result.json"), b"{}");
34110        let candidates = collect_result_json_candidates(&root);
34111        assert!(!candidates.is_empty(), "should find result.json at root");
34112        let _ = fs::remove_dir_all(&root);
34113    }
34114
34115    #[test]
34116    fn collect_result_json_candidates_legacy_subdir() {
34117        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34118        let sub = root.join("scanA");
34119        let _ = fs::create_dir_all(&sub);
34120        let _ = fs::write(sub.join("result.json"), b"{}");
34121        let candidates = collect_result_json_candidates(&root);
34122        assert!(
34123            !candidates.is_empty(),
34124            "should find result.json in legacy subdir"
34125        );
34126        let _ = fs::remove_dir_all(&root);
34127    }
34128
34129    #[test]
34130    fn collect_result_json_candidates_structured_json_subdir() {
34131        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34132        let json_sub = root.join("scanB").join("json");
34133        let _ = fs::create_dir_all(&json_sub);
34134        let _ = fs::write(json_sub.join("result.json"), b"{}");
34135        let candidates = collect_result_json_candidates(&root);
34136        assert!(
34137            !candidates.is_empty(),
34138            "should find result.json inside <subdir>/json/"
34139        );
34140        let _ = fs::remove_dir_all(&root);
34141    }
34142
34143    #[test]
34144    fn collect_result_json_candidates_empty_dir() {
34145        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34146        let _ = fs::create_dir_all(&root);
34147        let candidates = collect_result_json_candidates(&root);
34148        assert!(candidates.is_empty());
34149        let _ = fs::remove_dir_all(&root);
34150    }
34151}